MCP protocol: a technical guide for developers 2026
How to build an MCP server from scratch: architecture, security, auth and production concerns. Step by step with the TypeScript SDK and code examples.

The MCP protocol has gone from Anthropic experiment to industry standard in 18 months. For developers and CTOs who want to build AI agents against their own systems, the question is no longer whether to use MCP, but how to implement it securely in production. This guide is technical: architecture, code, auth, monitoring.
If you are new to the protocol, start with our introduction to MCP. This guide assumes you know what MCP is and want to build something yourself.
How does the MCP architecture work technically?
The MCP protocol is built on JSON-RPC 2.0 over two transport types: stdio (local servers) and HTTP with Server-Sent Events (remote servers). The client initiates a handshake where server and client exchange capabilities, after which bidirectional communication flows with structured messages.
The architecture has three layers. The top layer is the client (Claude Desktop, Cursor, or a custom app using the Anthropic SDK). The middle layer is the transport protocol carrying messages, either via standard input/output for local processes or HTTP+SSE for network services. The bottom layer is the MCP server itself, exposing Resources, Tools, and Prompts.
The handshake flow is standardized. The client sends initialize with its protocol version and the capabilities it supports. The server responds with the same fields plus server info. Both sides negotiate what they can do. This is documented in the official protocol specification.
What makes the MCP protocol different from a regular REST API? Three things that matter in implementation:
Stateful sessions. An MCP connection has long-lived state. Client and server remember what they have negotiated. REST is stateless per request — MCP is more like WebSocket.
Capability negotiation. The server tells the client exactly which Resources, Tools, and Prompts it supports at startup. No guessing, no Swagger file to maintain separately.
Notification support. The server can push updates to the client (for example "resource X has changed"). REST requires polling or separate webhook systems.
How do you build an MCP server from scratch?
The easiest way to build an MCP server is the TypeScript SDK from Anthropic. You install the package, define your tools or resources, and run the server via stdio for local calls or HTTP for remote. A minimal server is about 30 lines of code.
Installation:
npm install @modelcontextprotocol/sdk
Here is a minimal MCP server exposing a single tool that fetches today's date:
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: 'date-server',
version: '1.0.0',
})
server.tool(
'get_current_date',
'Fetches today\'s date in ISO 8601 format',
{
timezone: z.string().optional().describe('IANA timezone, e.g. Europe/Stockholm'),
},
async ({ timezone = 'Europe/Stockholm' }) => {
const date = new Date().toLocaleString('sv-SE', { timeZone: timezone })
return {
content: [{ type: 'text', text: date }],
}
}
)
const transport = new StdioServerTransport()
await server.connect(transport)
The code does three things. It creates a server instance with name and version, registers a tool with a Zod schema for input validation, and connects the server to the stdio transport. When Claude Desktop starts the server, the user will see get_current_date as an available tool.
To expose Resources (data the client can read) instead of Tools (functions that execute), you use server.resource(). The difference is semantic: Resources are read-only data, Tools are actions that can have side effects. The official TypeScript SDK documentation has examples for both patterns.
Python developers use the Python SDK with the same decorator-based pattern. Both SDKs sit at 3,000+ GitHub stars and are updated regularly by Anthropic.
For production deployment you switch transport from stdio to HTTP+SSE. That requires a couple of extra lines to start an Express server or similar HTTP runtime. The server then becomes accessible over the network instead of only locally.
How do you secure auth and permissions in MCP?
The MCP protocol has no built-in auth layer. You implement security in the transport layer or per tool. Three common patterns: API-key-based auth for simple scenarios, OAuth 2.1 for enterprise and third-party integrations, and mTLS for server-to-server within the same infrastructure.
For local stdio servers, auth is usually not an issue since the user runs the process themselves with their own permissions. For HTTP-based remote servers, auth becomes critical. Here is the pattern for an API key in a Bearer token, which is the simplest secure option:
import express from 'express'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
const app = express()
app.use('/mcp', (req, res, next) => {
const auth = req.headers.authorization
const expected = `Bearer ${process.env.MCP_API_KEY}`
if (!process.env.MCP_API_KEY) {
return res.status(503).json({ error: 'MCP_API_KEY not configured' })
}
if (auth !== expected) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
})
const server = new McpServer({ name: 'protected-server', version: '1.0.0' })
// ...register tools here...
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() })
await server.connect(transport)
app.use('/mcp', transport.handler)
app.listen(3000)
Scoping is the next layer. Not every client should be able to call every tool. Per-tool authorization can be implemented by verifying the client's identity (via JWT claims or similar) inside the tool handler and returning a permission error if the scope is missing.
For OAuth 2.1-based auth you follow the standard flow with a separate OAuth server that issues access tokens. The MCP protocol has an official auth specification describing how tokens are passed in Bearer headers. Anthropic recommends OAuth for public exposure of MCP servers.
Sandbox isolation matters for enterprise. If your MCP server runs against a production database, set strict SQL permissions on the user the server runs as. Never SUPERUSER. Never DROP TABLE. The standard principle is least privilege per tool.
How do you test and debug MCP servers?
The official tool is the MCP Inspector: a web-based UI that lets you call tools, inspect responses, and watch the entire JSON-RPC traffic in real time. You install it with npm and point it at your local or remote server.
Installation and startup:
npx @modelcontextprotocol/inspector node ./dist/server.js
This starts the Inspector on localhost:5173 and spawns your server as a child process via stdio. The UI shows a list of all registered tools, resources, and prompts. You can call each tool with custom input and see exactly what the server responds.
For HTTP-based servers you enter the URL and any auth header. The Inspector handles SSE streaming and shows the request/response pairs in a timeline. This is invaluable for debugging capability negotiation and tool-call errors.
Server-side logging is just as important as the Inspector. Since the MCP protocol uses stdio for local servers, regular console.log calls are dangerous. They corrupt the JSON-RPC stream and break the client connection immediately. Use console.error (stderr) for all logging, or a structured logger like pino. For HTTP servers, regular request logging applies without the stdio risk.
Three common bugs we have seen in consulting work for SMBs:
Tool schemas do not match reality. A tool declares that email is required, but the handler crashes on email: null. Solution: use Zod (or similar) for strict input validation, not just TypeScript typing.
Long-running tools time out. Claude Desktop has a default timeout of about 30 seconds per tool call. If your tool makes a heavy API call (or waits for an external process), implement progress notifications via MCP's notification mechanism, or break the work into smaller calls.
Resource URIs collide. If two resources have the same URI, one overwrites the other. Solution: use hierarchical URI design (db://customers/123 instead of customer-123).
For deeper troubleshooting, read the Inspector documentation on GitHub.
Which production considerations exist?
Three things are critical in production: rate limiting so a buggy client does not fetch 10,000 records per minute, error handling so failures do not leak stack traces to the client, and observability so you can react to incidents.
You implement rate limiting in the transport layer. For HTTP-based servers, standard libraries like express-rate-limit work directly. Start at 60–100 requests per minute per API key and adjust based on actual usage. For stdio servers, rate limiting is less critical since the client runs locally and is controlled by the user.
Error handling in the MCP protocol follows the JSON-RPC convention. Return a structured error object with code, message, and optional data:
server.tool('fetch_customer', 'Fetch customer data', { id: z.string() }, async ({ id }) => {
try {
const customer = await db.query('SELECT * FROM customers WHERE id = $1', [id])
if (!customer) {
return {
content: [{ type: 'text', text: `Customer with id ${id} was not found` }],
isError: true,
}
}
return { content: [{ type: 'text', text: JSON.stringify(customer) }] }
} catch (err) {
// Log the full error server-side, return a sanitized message
console.error('fetch_customer error:', err)
return {
content: [{ type: 'text', text: 'Internal error while fetching customer' }],
isError: true,
}
}
})
Never return raw exception messages to the client. They can contain credentials, file paths, or other sensitive information that the AI model then repeats in its answer to the end user.
Secrets management belongs in the same category. API keys and database credentials should come in via environment variables or a secrets manager (Vault, AWS Secrets Manager, Doppler), never hardcoded in the server code or in the client's config file. Remember that the client's MCP config often sits in plain text on the user's machine: everything in it should be considered visible to the user.
Versioning is the next question. The MCP protocol carries a protocol version in the handshake (2025-11-25 is the current dated specification). You set your server's own version in the McpServer constructor. Semantic versioning is recommended: breaking changes in tool signatures = major bump.
Observability: log every tool call with tool name, client identifier, duration, and success/failure. This lets you see which tools are actually used, which are slow, and which return errors. Standard loggers like Pino or Winston work fine. For enterprise: ship logs to Datadog, Honeycomb, or equivalent.
A concrete monitoring pattern that works in production:
import { performance } from 'perf_hooks'
server.tool('fetch_data', 'Fetches data', { id: z.string() }, async ({ id }) => {
const start = performance.now()
const clientId = process.env.MCP_CLIENT_ID ?? 'unknown'
try {
const result = await fetchFromSource(id)
const duration = performance.now() - start
logger.info({ tool: 'fetch_data', clientId, duration, status: 'success' })
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
} catch (err) {
const duration = performance.now() - start
logger.error({ tool: 'fetch_data', clientId, duration, status: 'error', err })
return { content: [{ type: 'text', text: 'Internal error' }], isError: true }
}
})
The four fields that matter in monitoring: tool (which tool), clientId (who called), duration (latency measurement), and status (success/error). With these you can build dashboards showing tool usage per client, p95 latencies per tool, and error rates over time. This is the minimum needed to debug incidents and prioritize optimizations in production.
How do you integrate MCP with enterprise systems?
Three major categories of enterprise integrations dominate in consulting work: relational databases (Postgres, SQL Server), internal REST APIs with existing SSO, and SaaS systems like Salesforce or HubSpot. Each type has its own pattern for authentication, write protection, and logging, and all three can reach production with wrapper servers.
For Postgres integration there is an official MCP server in github.com/modelcontextprotocol/servers. It exposes tables as Resources and lets the client run read-only queries via Tools. For SMBs this is often enough: the salesperson asks Claude in natural language, Claude translates to SQL, the server runs against a read replica.
For custom enterprise APIs we recommend a wrapper pattern: write an MCP server that internally calls your REST API with a service account. The client sees MCP tools, but under the hood authentication happens via OAuth or mTLS against your existing backend. This isolates the AI model from internal implementation details and lets you log and audit all AI-driven traffic centrally.
For SaaS integrations, first check whether an official MCP server exists. The MCP registry has 6,850+ servers as of May 2026, including official ones from Anthropic for Slack, GitHub, and Google Drive. Is your SaaS not covered? Build a wrapper server against their REST API. Expect 1–2 days per integration for production-grade quality.
Three things to keep in mind for enterprise integration:
Audit logging is not optional. Every tool call should be logged with client identity, payload, and result to a separate audit trail. This is often a requirement from the compliance or security team. For companies covered by GDPR this is especially important. For a deeper walkthrough of the regulatory requirements, read our guide to the EU AI Act.
Sandbox database for demos. Before an MCP server goes against production, test against a sandbox copy. AI-generated SQL queries can be creative in ways that surprise you.
Limit scope per role. An MCP server exposing the entire CRM gives the AI model access to the entire CRM. Want to limit it to "only this user's accounts"? Implement scoping in the tool handler based on client identity.
What does an enterprise-grade MCP implementation cost?
A realistic cost for an SMB is 40–80 developer hours for the first MCP server, plus hosting and operations at 200–2,000 SEK per month depending on traffic. The first server is the most expensive because the infrastructure is built then. Servers two and three take 8–15 hours each.
The cost breakdown for a typical enterprise MCP server:
| Component | Hours | Explanation |
|---|---|---|
| Server skeleton + tools | 8–12 | Initial setup, first 3–5 tools |
| Auth + scoping | 6–10 | OAuth or API key + per-tool permissions |
| Error handling + logging | 4–8 | Structured logging, audit trail |
| Testing + Inspector verification | 6–10 | Unit tests + end-to-end via Inspector |
| Production deployment | 4–8 | Containerization, secrets handling, monitoring |
| Documentation + handover | 4–8 | README, runbooks, training for internal staff |
| Total | 32–56 | First server, single system |
Additional costs vary. Hosting: an MCP server on Vercel, Railway, or Fly.io costs 100–500 SEK per month for reasonable traffic. Monitoring: Datadog or equivalent adds 500–2,000 SEK per month. Security review: an internal audit adds 8–16 hours to the first delivery, less for subsequent ones.
For a cost overview of AI agent projects in a broader sense, read our cost guide for AI agents. An MCP server is often a partial cost within a larger AI agent project.
On the difference between building an MCP server and using a ready-made chatbot solution, read our comparison of AI agent vs chatbot. The MCP protocol is what separates a real autonomous agent from a wrapped LLM call. For a broader introduction to how these pieces fit together for SMBs, see our pillar article on AI agents for SMBs.
Is the MCP protocol worth the investment? For Claude-based workflows the answer is clearly yes. For OpenAI-heavy organizations the answer is "wait until OpenAI ships official support, or build a proxy server if you cannot wait". Our assessment is based on 18 months of adoption data since Anthropic launched the protocol, and adoption is accelerating.
Frequently asked questions
Stdio for local dev tools (Claude Desktop, Cursor) where the server runs on the same machine as the client. HTTP+SSE for remote servers that multiple clients should reach over the network. Stdio is simpler to set up but limited to local use. HTTP requires more infrastructure but gives production-ready deployment with standard auth and rate limiting.
Yes. There are community SDKs for Go, Rust, Java, and C#. The protocol is language agnostic since it is built on JSON-RPC 2.0 over stdio or HTTP. Official SDKs from Anthropic exist for TypeScript and Python and receive the most maintenance. For other languages, check the awesome-mcp-servers list for current options.
Semantic versioning per server. Bump the major version when you change tool signatures or remove resources. Clients can see the server's version in the handshake. For enterprise deployments, run the old version in parallel for 30–90 days until all clients have migrated. This is the same pattern as API versioning in general.
Yes, via progress notifications. The server sends progress messages to the client while the operation runs. Notifications are part of the JSON-RPC specification and supported by both official SDKs. It is not streaming in the HTTP sense, though: it is discrete progress updates with percentage values or status messages.
Use the MCP SDK's in-memory transport for unit tests instead of stdio or HTTP. It lets you call tools programmatically in Jest or Vitest without spawning a separate process. For end-to-end tests, run the Inspector in headless mode or write a simple client with the SDK that calls your server and verifies responses.
The client gets a connection error and treats it as a tool failure. Good practice is to implement health checks and auto-restart in your runtime (Docker, PM2, systemd). For critical tools, design idempotent operations so retries are safe. Logging crashed sessions helps you find the root cause.
AI to work?



