Building
MCP servers explained: how the Model Context Protocol works, how to build one, and how to secure it
What an MCP server is, how the Model Context Protocol works after the stateless 2026 spec, a TypeScript server, OAuth, and the security incidents to learn from.
In short
An MCP server exposes what your product can do, as tools, resources and prompts, to any AI application that speaks the Model Context Protocol, such as Claude, ChatGPT and VS Code. MCP is an open standard governed since December 2025 by the Agentic AI Foundation under the Linux Foundation, and its 2026-07-28 specification made it stateless: no handshake and no sessions, so a server scales like an ordinary API. Local servers run over stdio with credentials from the environment; remote servers use Streamable HTTP and OAuth 2.1, where the server must validate that each token was issued for it and must never pass tokens through. Good tools are shaped around tasks, return small structured results and act with the signed-in user’s permissions. The documented incidents, from prompt injection through support tickets to forged tokens and malicious npm packages, are prevented by least-privilege credentials, fail-closed authentication, validated arguments, pinned dependencies and a person approving risky writes.
Key takeaways
- An MCP server lets every MCP-capable AI client use your product through one integration.
- The 2026-07-28 spec made MCP stateless: no initialize handshake and no sessions, so servers scale like APIs.
- Remote servers use OAuth 2.1: validate every token’s audience, fail closed, and never pass tokens through.
- Design tools around tasks, write their descriptions for the model, and return small structured results.
- Most incidents came from broad credentials meeting untrusted text: give each tool the user’s permissions, not an admin key.
- Pin dependencies, validate paths and arguments, and keep the SDK current.
What is an MCP server?
The Model Context Protocol (MCP) is an open standard for connecting AI applications to tools and data. An MCP server exposes what your product can do, such as looking up an order, creating a ticket or searching documents, in a form any MCP-capable AI application can use. Build one server, and Claude, ChatGPT, VS Code and other clients can all call it, instead of you writing a separate integration for each.
MCP started at Anthropic and, since December 2025, belongs to the Agentic AI Foundation, a fund under the Linux Foundation co-founded by Anthropic, Block and OpenAI. The current specification is version 2026-07-28.
When do you need an MCP server?
You need one when the answer to “which AI client will call this?” is “several, and not all of them ours”. Typical cases:
- Your customers want to use your product from their AI assistant: “show me last month’s invoices” asked in Claude or ChatGPT.
- Your team uses several AI tools and each needs the same internal systems: the CRM, the order database, the docs.
- You’re building agents and want their tools in one place, versioned and permissioned, rather than copied into each agent.
You don’t need one when a single application calls a single model with a few functions of your own. Plain function calling in your code is simpler, and MCP adds a network boundary you don’t need yet. See how to build an AI agent for that case.
How does MCP work?
There are three roles. The host is the AI application, such as Claude or an IDE. Inside it, an MCP client holds a connection to exactly one server. Messages are JSON-RPC 2.0.
A server offers three kinds of thing, distinguished by who decides to use them:
| Primitive | Who controls it | Example |
|---|---|---|
| Tools | The model decides when to call them | find_orders(email), create_ticket(title, body) |
| Resources | The application decides what to load | A document, a database schema, a file |
| Prompts | The user picks them | A saved “summarize this account” template |
Most servers are mostly tools.
Two transports
- stdio, for local servers. The host starts your server as a child process and talks over standard input and output. The server must not write anything to stdout that isn’t a valid MCP message, so log to stderr. Credentials come from environment variables.
- Streamable HTTP, for remote servers. One HTTP endpoint, usually
/mcp, that accepts POST. Every request carries anMCP-Protocol-Versionheader. The older HTTP+SSE transport has been deprecated since 2025, and new servers shouldn’t use it.
What changed in 2026: MCP went stateless
The 2026-07-28 revision is the biggest change since MCP launched. In the project’s words, MCP is “transforming from a bidirectional stateful protocol into a request/response stateless protocol.”
- No handshake. The
initializeexchange is gone. Every request carries its protocol version and the client’s capabilities, and servers must answer aserver/discoverrequest describing themselves. - No sessions. The
Mcp-Session-Idheader is removed. If a workflow needs state, a tool returns an explicit handle, such as a draft id, and the model passes it back as an argument. - Fewer server-to-client calls. Requests the server used to send back to the client are replaced by multi-round-trip requests, and sampling, roots and logging are deprecated.
For anyone running a server, statelessness is good news: any instance can answer any request, so an MCP server scales like an ordinary API, on serverless functions and behind a load balancer, with no sticky sessions.
How do you build an MCP server?
The official TypeScript SDK is at version 2.3 and implements the 2026-07-28 spec, split into @modelcontextprotocol/server and @modelcontextprotocol/client. Here is a small server with one task-shaped tool, run locally over stdio:
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { db } from './db'; // your own data layer
const server = new McpServer({ name: 'orders', version: '1.0.0' });
server.registerTool(
'find_orders',
{
description:
'Find a customer’s recent orders by email. Use for questions like “what did they order?”. ' +
'Returns at most 20 orders, newest first, each with its shipment status.',
inputSchema: z.object({
email: z.email().describe('The customer’s email, e.g. ada@example.com'),
since: z.iso.date().optional().describe('Only orders on or after this date; defaults to 90 days ago'),
}),
},
async ({ email, since }) => {
const orders = await db.findOrders(email, since, { limit: 20 });
if (orders.length === 0) {
return { content: [{ type: 'text', text: `No orders for ${email}. Check the spelling, or search by name.` }] };
}
return { content: [{ type: 'text', text: JSON.stringify(orders) }] };
},
);
await server.connect(new StdioServerTransport());To serve the same tools remotely, the SDK’s handler builds a server per request, which suits the stateless spec. Its Express helper already applies DNS-rebinding protection:
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'orders', version: '1.0.0' });
// register the same tools as above
return server;
});
const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));
app.listen(3000); Python teams have the official `mcp` package, where the server class is now MCPServer (it was FastMCP).
Test before any AI touches it. The MCP Inspector lists your tools and calls them by hand, and Claude Code can add the server in one line:
# Call your tools by hand
npx @modelcontextprotocol/inspector node dist/server.js
# Add a remote server to Claude Code
claude mcp add --transport http orders https://mcp.example.com/mcp How does MCP authorization work?
Local stdio servers take credentials from the environment and shouldn’t use the OAuth flow at all. Remote servers use OAuth 2.1, with your MCP server acting as the resource server and an identity provider you choose as the authorization server:
- A client calls your server without a token and gets
401with aWWW-Authenticateheader pointing to your Protected Resource Metadata (RFC 9728). Serving that document, with itsauthorization_serverslist, is a must. - The client discovers your authorization server’s metadata and registers, preferably with a Client ID Metadata Document; dynamic client registration is now deprecated.
- The user signs in and consents. The client requests a token for your server specifically, using resource indicators (RFC 8707).
- Your server validates every token: signature, expiry and, critically, that its audience is your server. Invalid tokens get
401.
Two rules matter more than the rest. Your server must not accept tokens issued for another service, and it must not pass the token it received through to downstream APIs. If it calls another API, it uses its own credentials for that API, scoped to the signed-in user. Tokens also never go in a URL query string.
How do you design good MCP tools?
An MCP server is an API whose caller is a language model. That changes what good design means.
Shape tools around tasks, not endpoints
The tempting first version mirrors your REST API: one tool per endpoint. A model then needs four calls and four rounds of reasoning to answer “what did this customer order last month?”, and every intermediate result fills its context. Shape tools around the questions people ask instead:
| Endpoint-shaped | Task-shaped |
|---|---|
get_customer, list_orders, get_order, get_shipment | find_orders(customer_email, since) returning orders with their shipment status |
update_ticket with any field | assign_ticket(ticket_id, assignee) and close_ticket(ticket_id, resolution) |
run_sql(query) | revenue_by_month(from, to) and other named, parameterized reports |
The last row matters most. A raw SQL tool hands the model, and anyone who can steer it, your whole database.
Write descriptions for the model
The tool name, its description and each parameter’s description are what the model reads when it decides which tool to call and how. Say when to use the tool, when not to, what each parameter means with an example, and what comes back. Vague descriptions produce wrong calls that look like model failures.
Return little, and return it structured
Every result lands in the model’s context window, where it costs tokens and pushes out earlier instructions. Return the fields the task needs, cap list sizes, paginate with a cursor, and summarize large payloads instead of dumping them.
Separate reading from writing
Put read-only tools and tools that change things in different groups, and keep anything destructive or irreversible narrow and explicit. Hosts can then let reads run freely and ask a person before a write.
Make errors useful
When a call fails, return a message the model can act on, such as “No customer with that email; try find_customer by name”, rather than a stack trace. A good error turns a dead end into a second, correct call.
Act as the user, never as an admin
A remote server should act with the permissions of the person who signed in, not with one shared key that can see everything. That rule is what keeps one user’s prompt from reading another user’s data.
How do you secure an MCP server?
An MCP server gives a model hands, so its security problems are the old ones, input validation, authentication and least privilege, plus one new one: the model reads text that an attacker can write.
The lethal trifecta
Simon Willison named the core risk: an agent that combines access to private data, exposure to untrusted content and the ability to communicate externally can be talked into sending your data out. Two public demonstrations show it with real MCP servers:
- Supabase, 2025. An attacker filed a support ticket containing instructions. When a developer asked their coding agent about the ticket, the agent, connected with an admin key that bypasses row-level security, read a table of integration tokens and posted them back into the ticket. The researchers’ summary: “No permissions were violated. The agent just followed instructions it should never have trusted.”
- GitHub, 2025. Instructions hidden in an issue on a public repository led an agent to leak data from a private repository into a public pull request. The researchers were explicit that this was not a bug in the server’s code.
Neither needed a software vulnerability. Both needed one agent holding broad credentials while reading text from strangers.
What has actually gone wrong
| Incident | What went wrong | The defense |
|---|---|---|
| Tool poisoning (2025) | Hidden instructions in a tool’s description made an agent read SSH keys | Review tool descriptions; install only servers you trust |
| Deadbugz rug pull (2026) | A server changed its instructions after three calls, to target SSH and cloud credentials | Pin versions and alert when tool metadata changes |
| postmark-mcp (2025) | A malicious npm update added the attacker as BCC on every email | Pin dependencies and review updates |
| LiteLLM, CVE-2026-59822 (2026, exploited) | A forged bearer token fell back to an empty auth object | Validate every token and fail closed |
| mcp-atlassian, CVE-2026-77244 (2026, CVSS 10.0) | The HTTP transport accepted unauthenticated requests, using the operator’s credentials | Authenticate every request, with per-user credentials |
| MCP Inspector, CVE-2025-49596 (2025) | A web page could reach a local proxy and run commands | Bind to localhost and validate the Origin header |
| Filesystem server, CVE-2025-53110 (2025) | A path that merely started with the allowed folder’s name passed the check | Resolve the real path, then compare whole segments |
The LiteLLM flaw is on CISA’s catalog of vulnerabilities exploited in the wild, and the official SDKs have had their own fixes, including DNS-rebinding protection that was off by default in older TypeScript releases. Keep the SDK current.
Validate every token, and fail closed
For a remote server, authentication runs on every request, and anything unexpected is a refusal. With a JWT access token and the jose library:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = 'https://auth.example.com/';
const AUDIENCE = 'https://mcp.example.com/mcp'; // this server, and nothing else
const JWKS = createRemoteJWKSet(new URL('/.well-known/jwks.json', ISSUER)); // your provider's key URL
export class Unauthorized extends Error {}
/** The signed-in user, or a refusal. Never a fallback identity. */
export async function requireUser(authorization: string | undefined) {
if (!authorization?.startsWith('Bearer ')) throw new Unauthorized('missing token');
const { payload } = await jwtVerify(authorization.slice('Bearer '.length), JWKS, {
issuer: ISSUER,
audience: AUDIENCE, // a token issued for any other service is rejected
});
if (!payload.sub) throw new Unauthorized('token has no subject');
return { userId: payload.sub, scopes: String(payload.scope ?? '').split(' ') };
}Each tool then runs with that user’s permissions, not a shared admin key, so one user’s prompt can’t reach another user’s data.
Treat every argument as hostile
Arguments come from a model that may be following an attacker’s instructions. Use parameterized queries, never build shell commands from arguments, and check paths after resolving them, which is the fix for the filesystem server’s bugs:
import { realpath } from 'node:fs/promises';
import path from 'node:path';
const ROOT = await realpath('/srv/shared-docs');
/** Resolves symlinks and `..`, then requires the result inside ROOT. */
export async function safePath(requested: string) {
const full = await realpath(path.resolve(ROOT, requested));
if (full !== ROOT && !full.startsWith(ROOT + path.sep)) {
throw new Error('That path is outside the shared folder.');
}
return full;
} Checking ROOT + path.sep rather than ROOT alone is the whole point: /srv/shared-docs-private starts with /srv/shared-docs.
Follow the spec’s rules
The MCP security best practices are short and specific. Servers must not accept tokens that weren’t issued for them, proxy servers must ask each client for consent, clients must not open authorization URLs through a shell, and under the stateless spec, possession of a state handle must never count as authentication.
What should an MCP server launch checklist include?
- A handful of task-shaped tools, read-only first.
- OAuth with audience validation on remote servers, failing closed, with no token passthrough.
- Localhost and Origin checks on local servers.
- Per-user, least-privilege credentials. No admin or service-role keys behind a tool.
- A person approving writes wherever the agent also reads untrusted content and holds private data.
- Validated arguments: resolved paths, parameterized queries, no shell.
- Pinned, reviewed dependencies, and an alert when a third-party server’s tool descriptions change.
- A log of every call: user, tool, redacted arguments, result size and latency, with rate limits.
- A test pass with the Inspector and with adversarial content planted in the data the tools read.
- SDK updates on a schedule. Several 2026 fixes shipped in point releases.
What next?
List the five questions your customers or staff would most like to ask your product from an AI assistant. Those are your first tools. We build MCP servers and the agents that use them, with the authorization, logging and tests above; see AI agent and MCP server development. When you’re ready, send us the five questions.
Sources
- OpenAI — Connect a custom MCP server to ChatGPT
- VS Code — MCP servers
- MCP blog — MCP joins the Agentic AI Foundation (2025)
- MCP specification 2026-07-28 — Changelog
- MCP specification 2026-07-28 — Overview
- MCP specification — stdio transport
- MCP specification — Streamable HTTP transport
- MCP blog — The 2026-07-28 specification release
- MCP specification — Deprecated features
- MCP TypeScript SDK
- MCP Python SDK
- MCP Inspector
- MCP specification — Authorization
- MCP specification — Security considerations
- Simon Willison — The lethal trifecta (2025)
- General Analysis — Supabase MCP data leak (2025)
- Invariant Labs — GitHub MCP toxic agent flow (2025)
- Invariant Labs — Tool poisoning attacks (2025)
- Pillar Security — Deadbugz MCP supply-chain campaign (2026)
- Snyk — Malicious postmark-mcp package (2025)
- NVD — CVE-2026-59822, LiteLLM MCP endpoint
- NVD — CVE-2026-77244, mcp-atlassian
- GitHub advisory — MCP Inspector RCE, CVE-2025-49596
- NVD — CVE-2025-53110, Filesystem MCP server
- CISA — Known Exploited Vulnerabilities catalog
- NVD — CVE-2025-66414, MCP TypeScript SDK DNS rebinding
- MCP specification — Security best practices