PutThrough Product engineering studio
Book a free call

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:

What an MCP server exposes
PrimitiveWho controls itExample
ToolsThe model decides when to call themfind_orders(email), create_ticket(title, body)
ResourcesThe application decides what to loadA document, a database schema, a file
PromptsThe user picks themA 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 an MCP-Protocol-Version header. 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 initialize exchange is gone. Every request carries its protocol version and the client’s capabilities, and servers must answer a server/discover request describing themselves.
  • No sessions. The Mcp-Session-Id header 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:

  1. A client calls your server without a token and gets 401 with a WWW-Authenticate header pointing to your Protected Resource Metadata (RFC 9728). Serving that document, with its authorization_servers list, is a must.
  2. The client discovers your authorization server’s metadata and registers, preferably with a Client ID Metadata Document; dynamic client registration is now deprecated.
  3. The user signs in and consents. The client requests a token for your server specifically, using resource indicators (RFC 8707).
  4. 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 vs task-shaped tools
Endpoint-shapedTask-shaped
get_customer, list_orders, get_order, get_shipmentfind_orders(customer_email, since) returning orders with their shipment status
update_ticket with any fieldassign_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:

Neither needed a software vulnerability. Both needed one agent holding broad credentials while reading text from strangers.

What has actually gone wrong

Documented MCP incidents, and the defense each one teaches
IncidentWhat went wrongThe defense
Tool poisoning (2025)Hidden instructions in a tool’s description made an agent read SSH keysReview tool descriptions; install only servers you trust
Deadbugz rug pull (2026)A server changed its instructions after three calls, to target SSH and cloud credentialsPin versions and alert when tool metadata changes
postmark-mcp (2025)A malicious npm update added the attacker as BCC on every emailPin dependencies and review updates
LiteLLM, CVE-2026-59822 (2026, exploited)A forged bearer token fell back to an empty auth objectValidate every token and fail closed
mcp-atlassian, CVE-2026-77244 (2026, CVSS 10.0)The HTTP transport accepted unauthenticated requests, using the operator’s credentialsAuthenticate every request, with per-user credentials
MCP Inspector, CVE-2025-49596 (2025)A web page could reach a local proxy and run commandsBind 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 checkResolve 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?

  1. A handful of task-shaped tools, read-only first.
  2. OAuth with audience validation on remote servers, failing closed, with no token passthrough.
  3. Localhost and Origin checks on local servers.
  4. Per-user, least-privilege credentials. No admin or service-role keys behind a tool.
  5. A person approving writes wherever the agent also reads untrusted content and holds private data.
  6. Validated arguments: resolved paths, parameterized queries, no shell.
  7. Pinned, reviewed dependencies, and an alert when a third-party server’s tool descriptions change.
  8. A log of every call: user, tool, redacted arguments, result size and latency, with rate limits.
  9. A test pass with the Inspector and with adversarial content planted in the data the tools read.
  10. 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

  1. OpenAI — Connect a custom MCP server to ChatGPT
  2. VS Code — MCP servers
  3. MCP blog — MCP joins the Agentic AI Foundation (2025)
  4. MCP specification 2026-07-28 — Changelog
  5. MCP specification 2026-07-28 — Overview
  6. MCP specification — stdio transport
  7. MCP specification — Streamable HTTP transport
  8. MCP blog — The 2026-07-28 specification release
  9. MCP specification — Deprecated features
  10. MCP TypeScript SDK
  11. MCP Python SDK
  12. MCP Inspector
  13. MCP specification — Authorization
  14. MCP specification — Security considerations
  15. Simon Willison — The lethal trifecta (2025)
  16. General Analysis — Supabase MCP data leak (2025)
  17. Invariant Labs — GitHub MCP toxic agent flow (2025)
  18. Invariant Labs — Tool poisoning attacks (2025)
  19. Pillar Security — Deadbugz MCP supply-chain campaign (2026)
  20. Snyk — Malicious postmark-mcp package (2025)
  21. NVD — CVE-2026-59822, LiteLLM MCP endpoint
  22. NVD — CVE-2026-77244, mcp-atlassian
  23. GitHub advisory — MCP Inspector RCE, CVE-2025-49596
  24. NVD — CVE-2025-53110, Filesystem MCP server
  25. CISA — Known Exploited Vulnerabilities catalog
  26. NVD — CVE-2025-66414, MCP TypeScript SDK DNS rebinding
  27. MCP specification — Security best practices

Written by

PutThrough

A product engineering studio that builds AI agents, automations, web and mobile apps, and audits apps built with AI coding tools.

Questions

Questions this raises

Something we haven’t covered?

Ask on a call
01 What is an MCP server?

An MCP server is a program that exposes tools, resources and prompts over the Model Context Protocol, so AI applications such as Claude, ChatGPT and VS Code can use your product or data. The model decides when to call a tool, such as find_orders or create_ticket, and the server runs it with the signed-in user’s permissions.

02 What is the Model Context Protocol?

The Model Context Protocol (MCP) is an open standard for connecting AI applications to external tools and data, using JSON-RPC 2.0 messages between a client inside the AI application and a server. It started at Anthropic and has been governed since December 2025 by the Agentic AI Foundation, a fund under the Linux Foundation. The current specification is version 2026-07-28.

03 How do you build an MCP server?

Use an official SDK. In TypeScript, create an McpServer from @modelcontextprotocol/server, register each tool with a description and an input schema, and connect it to a transport: stdio for a local server, or Streamable HTTP for a remote one. Test it with the MCP Inspector before connecting a client such as Claude Code.

04 What changed in the 2026-07-28 MCP specification?

MCP became a stateless request-and-response protocol. The initialize handshake and the Mcp-Session-Id header were removed, every request carries its protocol version and client capabilities, servers must answer server/discover, and sampling, roots, logging and dynamic client registration were deprecated. State now travels as explicit handles passed as tool arguments.

05 How does MCP authentication work?

Local stdio servers read credentials from the environment. Remote servers act as OAuth 2.1 resource servers: they publish Protected Resource Metadata naming their authorization server, clients request tokens for that server with resource indicators, and the server validates every token’s signature, expiry and audience. Servers must not accept tokens issued for other services or pass the client’s token through to other APIs.

06 Is MCP secure?

The protocol can be used securely, but many servers haven’t been. Documented incidents include prompt injection through support tickets and GitHub issues, unauthenticated HTTP transports, a forged-token flaw now exploited in the wild, malicious npm packages and path-check bugs. Least-privilege per-user credentials, fail-closed token validation, validated arguments, pinned dependencies and human approval for risky writes prevent most of them.

07 What is the difference between MCP and function calling?

Function calling is how one model calls functions that one application defines. MCP standardizes how any AI application discovers and calls tools that a separate server provides, so the same server works across clients. For one app with one model and a few functions of its own, plain function calling is simpler.

08 Does ChatGPT support MCP servers?

Yes. ChatGPT can add a custom MCP server reachable at a public HTTPS endpoint that supports Streamable HTTP, typically at /mcp. Claude Code adds one with claude mcp add, and VS Code reads servers from an mcp.json file or its MCP: Add Server command.

Have something to build?

A free 30-minute call. We’ll tell you honestly what fits in a week, and send a fixed quote afterward.

Prefer to write? Send your requirements

What do you need?

What it should do, who will use it, the must-haves, and any links. A few sentences is plenty.

Budget, if you have one in mind
When would you like to start?