MCP: Connecting Models to Your Systems
You already wrote this connector. Last quarter it was for the support bot: authenticate against the orders API, page through the results, reshape the dates it hands back in that annoying format. It worked. Then engineering standardised on a different AI-powered IDE, product wanted the same order data inside their own assistant, and none of that work moved an inch. Each app wanted its tools declared in its own shape, registered through its own config, run inside its own process. You are staring at writing the same integration a third time, slightly differently, for no good reason.
That repetition is the problem the Model Context Protocol removes. Before a standard existed, every AI application invented its own way to plug in tools and data, so a connector built for one could not be reused by another. MCP is an open protocol for that plug. An app that speaks it can talk to any tool or data source that also speaks it, with no bespoke glue in between. As of 2026 it has become the common language here: the major chat apps, IDE assistants, and coding agents support it, and there are ready-made servers for a long list of everyday systems.
If you have used a modern code editor, you have already benefited from the same trick. Editors used to hand-build support for each language: autocomplete for one, go-to-definition for another, all bespoke. Then the Language Server Protocol arrived, each editor learned to speak it once, each language shipped one server, and suddenly every editor got every language for free. MCP is that idea pointed at AI apps and tools. Learn the shape once here and you can both publish your systems to any client and consume anyone else’s server without hand-rolling a thing.
The mess it replaces
Count the connectors. If you have three AI apps that each need to reach three systems, and every app wires up every system its own way, that is nine integrations to build and keep alive. Add a fourth app and you owe three more. The work grows with the two numbers multiplied together, and every one of those connectors is a slightly different reimplementation of “let the model read orders and search docs”.
The right side is the whole economic argument. Speak the protocol once on each side and the count becomes apps plus systems. Your orders server, written a single time, is now reachable from the chat app, the IDE agent, and the cron bot without any of them knowing a thing about your database. This is exactly the tool-calling round trip you already know, lifted out of one app and put behind a standard interface so it stops being copy-pasted.
Host, client, server
Three words carry the whole architecture, and it is worth getting them straight because people mix up the middle one.
The host is the AI application: the chat app, the IDE assistant, the agent you are building. It owns the model and the conversation. For every server it wants to reach, the host creates one client, and that client holds a single dedicated connection to one server. The server is a program that exposes capabilities. It can run as a local subprocess on the same machine or as a remote service across the network, and the host does not much care which.
The part that trips people: one client, one server, always. Connect to five servers and the host is running five clients side by side. Each connection is independent, negotiated on its own, and the host is the thing that stitches all five servers’ capabilities together into the single menu it shows the model.
Three things a server exposes
A server offers capabilities in three flavours. They look similar on the wire but differ in one important way: who decides when each one gets used.
- Tools are actions the model can run.
getOrder,searchTickets,createIssue. These are model-controlled: the model reads their descriptions and decides, mid-conversation, when to call one. This is the tool-calling round trip, unchanged, just discovered over the protocol instead of hard-coded into the app. - Resources are data the model can read. A file’s contents, a database record, an API response, each identified by a URI like
orders://schemaorfile:///readme.md. Resources are application-controlled: the host or the user chooses what to pull into context, the way you decide which files to attach to a message. The model does not reach out and grab them on a whim. - Prompts are reusable, templated instructions. A “summarise this incident” template, a “review this diff” workflow with the right system message and arguments baked in. Prompts are user-controlled: they usually surface as something a person picks on purpose, like a slash command, not something the model triggers.
You will spend most of your time on tools, because tools are where the model acts. Resources and prompts matter, but if you only ever exposed tools you would still have a useful server. Start there.
It is JSON-RPC underneath
Strip away the SDKs and MCP is JSON-RPC 2.0: plain request and response objects with a method, some params, and an id to match replies to requests. Nothing exotic. A session opens with a handshake where the two sides announce what they support, then the client discovers and calls capabilities.
The handshake is one initialize call. The client says which protocol version and features it speaks, the server answers with its own, and they agree on a common footing:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": { "elicitation": {} },
"clientInfo": { "name": "example-client", "version": "1.0.0" }
}
}
That protocolVersion is a date, and client and server negotiate one they both understand. The spec revises on a dated cadence, so treat the exact string as a moving value, not a constant to hard-code. After initialize succeeds, discovery and use are just more method calls. List the tools, then call one:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "getOrder",
"arguments": { "orderId": "A-1024" }
}
}
The response to tools/call comes back as a content array, usually a block of text the model can read on its next turn. If you squint, the request is a tool call and the response is a tool result, which is precisely the round trip from tool calling. MCP has not invented a new interaction. It has standardised where that interaction happens and how the two sides find each other.
Wrapping your own system as a server
Here is the payoff for a JavaScript developer. Wrap your API or database as an MCP server once, and every compliant client can use it, no per-app integration required. The SDKs make the server itself small: you declare a tool with a name, a schema, and a handler, exactly the three fields from tool calling, and the SDK handles the JSON-RPC plumbing.
The shape below is illustrative, and the exact method names shift between SDK versions, so lean on your SDK’s current docs rather than memorising this:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "orders", version: "1.0.0" });
server.tool(
"getOrder",
{ orderId: z.string().describe("Order id, for example A-1024") },
async ({ orderId }, { session }) => {
// YOUR code, the only place anything runs. Validated and scoped.
const order = await db.query(
"SELECT id, status, total_cents FROM orders WHERE id = $1 AND tenant_id = $2",
[orderId, session.tenantId]
);
return { content: [{ type: "text", text: JSON.stringify(order) }] };
}
);
Notice what did not change from an ordinary tool: the argument is parsed against a schema, the query is parameterised, and the row is scoped to the caller’s tenant. Putting a tool behind MCP does not relax any of that. It is the same function you would write for tool calling, now reachable by clients you did not build.
Consuming a server is the other half, and often the bigger time-saver. Instead of hand-rolling a GitHub or Postgres or Slack integration, you point your host at an existing server and inherit its tools. Configuration is usually a small manifest: local servers are launched as a subprocess, remote ones are given a URL.
{
"mcpServers": {
"orders": { "command": "node", "args": ["orders-server.js"] },
"github": { "url": "https://mcp.example.com/mcp" }
}
}
That is the transport question in one snippet. A local server runs as a child process and the host talks to it over stdio, standard in and standard out, with no network and no ports. A remote server runs somewhere else and the host reaches it over HTTP, using the Streamable HTTP transport that replaced the older server-sent-events approach during 2025. You rarely touch the bytes either way; the SDK frames the JSON-RPC for you. What matters is the split: stdio for something on your machine, HTTP for something across the network, and authentication only enters the picture for the second one.
See a session run, with no model
Below is a mock MCP session you can drive. There is no model and no network anywhere on the page: a fake client talks to a hardcoded orders server, and every button sends one real-shaped JSON-RPC message and shows the reply. Start with initialize, then list and call. Try calling a method before you initialize and watch the server refuse, because the handshake is not optional.
Poke at the order in which you click. The lifecycle is real: a compliant server rejects tools/call until it has been initialized, capabilities are announced in the handshake, and every message carries the id that ties a response to its request. That is the entire protocol in miniature, and it is not much more than this at full size.
A server is an execution surface
Now the part that has to sit at the front of your mind, not the end. An MCP server is not a passive data feed. It is code that runs, its tools execute with whatever permissions you grant them, and the arguments flowing into those tools are produced by a model steered by everything in its context. Bolt a server onto a host and you have added an execution surface. Treat it like one.
Two things make this sharper than ordinary tool calling. First, the inputs are model-driven, so they are untrusted the same way a request body from the open internet is untrusted, and the same prompt injection risk applies: a user message, a pasted document, or a web page read through another tool can all nudge the model into calling your tool with arguments you never intended. Second, and this is the one people miss, connecting to a third-party server means trusting two separate things: its code, which runs with the access you hand it, and its tool descriptions, which flow straight into your model’s context. A hostile or sloppy description can carry hidden instructions, so a tool that claims to “look up the weather” can quietly try to talk your model into exfiltrating data. That attack has a name, tool poisoning, and it lands through the description, the parameter schema, or even the tool’s return value.
The defenses are not new inventions. They are the disciplines you already apply to any endpoint, pointed at the server boundary.
Authenticate the connection. For remote servers, do not roll your own token scheme. The protocol leans on OAuth for remote authorization (2.1 with PKCE as of 2026), which is the OAuth you already know applied to the client-server handshake. Two failure modes get their own names in the security guidance: a confused deputy, where a proxying server is tricked into using its own trust to grant access it should not, and token passthrough, where a server blindly forwards the caller’s token to some upstream API that never authenticated the caller. The short rule: validate that a token was actually issued for your server, and never relay a client’s token to a third party.
Scope permissions tightly. A server’s tools run with the access you give the process, so give it the least that works. The database role behind an orders server should be able to read orders and nothing else. This is ordinary authorization and multi-tenancy: the permission check lives in your code and your data layer, never in a tool description a model can be argued out of.
Validate every argument. The arguments in a tools/call are untrusted input, full stop. Parse them against a schema, reject anything off-shape, and never interpolate a model-generated string into a query, a shell command, or a path. This is the same validating input you do at every HTTP boundary, and it is the wall that a clever prompt cannot climb.
Vet servers before you trust them. Adding a third-party server is a dependency decision, and it deserves the scrutiny of one. Read what its tools claim to do, prefer servers you can inspect or that come from a source you already trust, and pin versions so a description cannot silently change under you. This is supply-chain security with a new twist: the dependency can attack you through prose aimed at your model, not just through code.
Where MCP fits, and where it does not
MCP is worth reaching for when the same capability needs to serve several AI apps, or when you want to consume an integration someone else already built and maintains. That is a real and common shape, and the standard genuinely saves you the N-times reimplementation. It is less obviously worth the ceremony when you have exactly one app calling exactly one set of tools that only you will ever use. In that case plain tool calling in-process is simpler, and you can always lift it behind a server later, because the tool you wrote is the same either way.
The protocol itself is young and moving. The primitives, the client-server split, and the security posture are stable enough to build on and to teach. The exact method names, transport details, and auth requirements shift release to release, so anchor to the concepts here and confirm the wire specifics against the current spec for the version your client and server negotiate.
Summary
- Before a standard, every AI app wired up tools its own way, so a connector built for one could not move to another. MCP is an open protocol that fixes the N-times-M integration sprawl by making it N plus M, the way the Language Server Protocol did for editors.
- The architecture is host, client, server. The host is the AI app, it runs one client per server, and each client holds a dedicated connection. The host composes many servers into one menu for the model.
- A server exposes three primitives: tools (actions, model-controlled, the tool-calling round trip), resources (data to read, application-controlled, URI-addressed), and prompts (reusable templates, user-controlled).
- Underneath it is JSON-RPC 2.0: an
initializehandshake to negotiate capabilities, thentools/listandtools/call,resources/listandresources/read,prompts/listandprompts/get. - Transport is stdio for a local subprocess and Streamable HTTP for a remote service. You rarely touch the bytes; authentication only matters for the remote case.
- The payoff for a JS developer: wrap your API or database as a server once and every client can use it, and consume existing servers instead of hand-rolling integrations.
- A server is an execution surface. Its tools run with the permissions you grant, its inputs are model-driven and therefore untrusted, and a third-party server is code plus tool descriptions you must trust, where descriptions can carry prompt injection.
- Defend it like any endpoint: authenticate with OAuth, scope permissions with authorization and multi-tenancy, validate every argument, and vet servers as a supply-chain decision. Tool annotations are hints, not guarantees.