Every MCP Tutorial From 2024 Is Outdated. Here’s How to Build One Safely in 2026.
Last Updated on May 29, 2026 by Editorial Team
Author(s): Vinamra Yadav
Originally published on Towards AI.
Every MCP Tutorial From 2024 Is Outdated. Here’s How to Build One Safely in 2026.

In January 2026, security researchers at Knostic scanned the internet for MCP servers. They found 1,862 running publicly — and when they manually tested 119 at random, every single one allowed access without credentials.
Not some of them. All of them.
The servers weren’t broken. They were built exactly as the tutorials described — tutorials written before authentication existed in the MCP spec.
The protocol shipped in November 2024 with no mandatory authentication. OAuth wasn’t added until March 2025. The HTTP+SSE transport was deprecated and replaced by Streamable HTTP in the same era of updates. Most tutorials sitting at the top of Google today were written before any of that happened.
If you followed one of those tutorials, you weren’t careless. The instructions were correct for the world they were written in. That world changed.
This article is the one that should have existed.
What MCP is — and why the spec changes matter
Model Context Protocol is the standard that lets AI tools — Claude, Cursor, Windsurf, Warp, GitHub Copilot, VS Code — connect to external servers to access your tools, data, and systems. USB-C for AI integrations: instead of every tool needing a custom connector, they all speak one protocol.
By mid-2026, over 9,400 distinct MCP servers were tracked across public registries. Claude, Cursor, Windsurf, Warp, Copilot, VS Code Agent Mode, Replit — all support it natively. It crossed from “interesting spec” to “operational standard” in roughly 18 months.
If you’re building anything that connects AI tools to real systems, you’re going to build an MCP server. The only question is whether you build it correctly.
What actually changed since those 2024 tutorials

Three things. All of them matter.
1. The transport changed
The original spec used SSE (Server-Sent Events) — a one-way streaming protocol with real limitations at scale: connection overhead, load balancer incompatibility, inefficient request-response patterns.
During the 2025 spec updates, HTTP+SSE was deprecated for new servers and replaced by Streamable HTTP — standard HTTP POST and GET to a single endpoint, with optional SSE streaming only for long-running operations. Load balancers handle it cleanly, no special proxy config needed. By 2026, new remote MCP servers should be on Streamable HTTP.
If your server config still has transport: sse, you're running a deprecated protocol.
2. Authentication isn’t optional — with one nuance
MCP launched without authentication because the first use case was local: a developer’s tools on their own machine. Local doesn’t need auth. But the spec didn’t draw that line clearly, and production teams missed it.
OAuth was added in March 2025. The authorization spec later tightened around OAuth 2.1-style flows and mandatory Resource Indicators (RFC 8707) — tokens are now bound to a specific MCP resource and can’t be silently reused across different endpoints. That’s what prevents token relay attacks, where a compromised server reuses a legitimate token to access a different server on your behalf.
The practical rule: any MCP server exposed over HTTP outside a purely local environment needs proper auth — OAuth 2.1, Protected Resource Metadata, Resource Indicators. Local stdio servers on your own machine don’t need OAuth. The moment you expose a server over HTTP to other clients, auth is not optional.
3. Tool poisoning is a real attack class
Beyond missing auth, there’s a second vector almost no tutorials address: tool poisoning. An attacker embeds malicious instructions inside a tool’s description or schema — not the tool’s output, but the metadata the LLM reads to decide how to use the tool.
The MCPTox benchmark — presented at AAAI 2026, testing 45 live MCP servers and 353 authentic tools — found attack success rates above 60% across modern LLMs. More capable models were often more susceptible because the attack exploits instruction-following ability, not a model weakness.
This matters when connecting to third-party servers. Your own server, built correctly, won’t have this problem — but knowing it exists determines which external servers you trust.
Building it correctly: A production-ready MCP server in Python
We’ll build a code review assistant that analyzes Python files for bugs and security issues. By the end: a server that runs locally in Claude Desktop and Cursor, and deploys remotely with Streamable HTTP and OAuth-style token validation.
Prerequisites:
- Python 3.12+
- FastMCP 3.0 — the framework powering approximately 70% of all MCP servers across all languages
pip install "fastmcp>=3.0"
FastMCP handles transport, schema generation, OAuth, and the JSON-RPC layer. You write Python functions.
Step 1: Server structure
# server.py
from fastmcp import FastMCP
from pathlib import Path
import os
mcp = FastMCP(
name="code-review-assistant",
instructions=(
"You are a code review assistant. Identify bugs, security issues, and "
"code quality problems in Python files. Be specific. Reference line numbers. "
"Prioritize security findings. Use analyze_file to review a file. "
"Read config://review-settings first to understand what standards apply."
)
)
The instructions field is consistently skipped in tutorials. It's what the LLM reads to understand your server's purpose and how to use its tools. Write it like briefing a teammate — specific and directive. Vague instructions produce vague behavior.
Step 2: Tools — documentation determines whether they get used
The most common mistake is treating a tool like a library function signature. The LLM reads your docstring to decide when to call the tool and what to pass. Vague descriptions mean wrong inputs, wrong timing, or the tool being ignored.
@mcp.tool
def analyze_file(file_path: str, focus: str = "all") -> dict:
"""
Analyze a Python file for bugs, security issues, and code quality problems.
Use this when the user wants to review a specific file.
Args:
file_path: Absolute path to the Python file. Example: /home/user/project/auth.py
focus: 'security', 'bugs', 'quality', or 'all'. Use 'security' when the user
mentions auth, tokens, passwords, or credentials.
"""
path = Path(file_path)
if not path.exists():
return {"error": f"File not found: {file_path}", "findings": []}
if path.suffix != ".py":
return {"error": "Only Python files are supported", "findings": []}
content = path.read_text(encoding="utf-8")
lines = content.splitlines()
findings = []
if focus in ("security", "all"):
for i, line in enumerate(lines, 1):
stripped = line.strip()
if not stripped.startswith("#"):
if "eval(" in line:
findings.append({"line": i, "severity": "critical", "type": "security",
"message": "eval() executes arbitrary code - never use with untrusted input",
"code": stripped})
if "subprocess" in line and "shell=True" in line:
findings.append({"line": i, "severity": "high", "type": "security",
"message": "subprocess with shell=True - vulnerable to shell injection",
"code": stripped})
if any(p in line.lower() for p in ["password =", "secret =", "api_key ="]):
if '"' in line or "'" in line:
findings.append({"line": i, "severity": "critical", "type": "security",
"message": "Possible hardcoded credential - use environment variables",
"code": stripped})
if focus in ("bugs", "all"):
for i, line in enumerate(lines, 1):
if "except:" in line and "except Exception" not in line:
findings.append({"line": i, "severity": "medium", "type": "bug",
"message": "Bare except swallows all errors including KeyboardInterrupt",
"code": line.strip()})
return {
"file": path.name,
"lines_analyzed": len(lines),
"findings": findings,
"summary": {
"critical": sum(1 for f in findings if f["severity"] == "critical"),
"high": sum(1 for f in findings if f["severity"] == "high"),
"medium": sum(1 for f in findings if f["severity"] == "medium"),
}
}
The parameter descriptions aren’t style — they’re behavioral specification. The difference between focus: str and the documented version is whether the LLM automatically uses "security" when you mention passwords.
Step 3: Resources — context, not just actions
Tools do things. Resources expose data the LLM can read before acting — the “read” side of your server.
@mcp.resource("config://review-settings")
def get_review_settings() -> dict:
"""Current code review configuration. Read before analyzing files."""
return {
"python_version": "3.12+",
"severity_levels": ["critical", "high", "medium", "low"],
"auto_fail_on": ["critical", "high"],
"checks_enabled": {"security": True, "bugs": True, "quality": True}
}
@mcp.resource("review://{file_path}")
def get_cached_review(file_path: str) -> str:
"""Most recent review for this file. Check before running a fresh analysis."""
return f"No cached review for {file_path}. Run analyze_file to generate one."
The URI template review://{file_path} is dynamic — FastMCP handles the routing.
Step 4: Transport — Streamable HTTP, not SSE
Pick your deployment target. Use one block, not both.
# Option A — Local only (stdio)
if __name__ == "__main__":
mcp.run()
# Option B - Remote deployment (Streamable HTTP)
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=int(os.environ.get("PORT", 8000)))
Or from the command line:
fastmcp run server.py # local
fastmcp run server.py --transport streamable-http --host 0.0.0.0 --port 8000 # production
For a single file that handles both:
if __name__ == "__main__":
if os.getenv("MCP_TRANSPORT") == "streamable-http":
mcp.run(transport="streamable-http", host="0.0.0.0", port=int(os.environ.get("PORT", 8000)))
else:
mcp.run() # stdio for local use
Step 5: Authentication — the section 1,862 deployments skipped
Local stdio server? Skip this. HTTP-exposed server? This is mandatory.
FastMCP 3.0 validates OAuth tokens on every request before your tool code runs.
from fastmcp import FastMCP
from fastmcp.server.auth import BearerAuthProvider
import os
auth = BearerAuthProvider(
jwks_uri=os.environ["AUTH_JWKS_URI"], # Auth server public key endpoint
issuer=os.environ["AUTH_ISSUER"], # Must match iss claim in tokens
audience=os.environ["MCP_SERVER_URL"], # Your server's URL
)
mcp = FastMCP(name="code-review-assistant", auth=auth, instructions="...")
On audience and resource binding: the MCP authorization spec requires tokens be issued specifically for your server. In JWT-based setups this is typically the aud claim — but the core requirement is resource binding itself, not the JWT field. A token for a different MCP server must be rejected. That's what stops token relay attacks.
For development, generate short-lived tokens against a local auth server. For production, use whatever identity provider your org has — Auth0, Keycloak, AWS Cognito, Azure AD all work with the jwks_uri pattern.
Full production server:
# server.py — production
from fastmcp import FastMCP
from fastmcp.server.auth import BearerAuthProvider
from pathlib import Path
import os
auth = BearerAuthProvider(
jwks_uri=os.environ["AUTH_JWKS_URI"],
issuer=os.environ["AUTH_ISSUER"],
audience=os.environ["MCP_SERVER_URL"],
)
mcp = FastMCP(
name="code-review-assistant",
auth=auth,
instructions=(
"Analyze Python files for bugs, security issues, and quality problems. "
"Reference line numbers. Prioritize security findings first."
)
)
@mcp.tool
def analyze_file(file_path: str, focus: str = "all") -> dict:
# implementation from Step 2
pass
@mcp.resource("config://review-settings")
def get_review_settings() -> dict:
# implementation from Step 3
pass
if __name__ == "__main__":
if os.getenv("MCP_TRANSPORT") == "streamable-http":
mcp.run(transport="streamable-http", host="0.0.0.0", port=int(os.environ.get("PORT", 8000)))
else:
mcp.run()
Put credentials in environment variables. The Clawdbot incident — 42,000+ exposed instances — involved credentials in plaintext server configs, extractable through unauthenticated endpoints. No sophistication required.
Step 6: Testing — Claude Desktop and Cursor
Test locally over stdio before thinking about deployment.
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
{
"mcpServers": {
"code-review-assistant": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}
Fully quit and reopen — Claude Desktop reads config once at startup. Ask Claude to review a Python file and the tool icon will appear.
Cursor (.cursor/mcp.json in project root or ~/.cursor/mcp.json globally):
{
"mcpServers": {
"code-review-assistant": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}
Cursor picks up changes automatically — no restart needed.
Always test in isolation before connecting to any AI tool:
# test_server.py
import asyncio
from fastmcp import Client
async def test():
async with Client("python server.py") as client:
tools = await client.list_tools()
print("Tools:", [t.name for t in tools])
result = await client.call_tool(
"analyze_file", {"file_path": "/path/to/test.py", "focus": "security"}
)
print("Result:", result)
asyncio.run(test())
If it fails here, it fails inside Claude — and terminal tracebacks are far easier to debug than Claude’s conversation UI.
Two things on the roadmap worth building toward
Agent Communication. The official MCP roadmap targets more reliable task lifecycle handling for agent-driven workflows. Keep tool handlers stateless and idempotent. Document them well enough for an agent with no prior session context to call correctly.
MCP Server Cards. Servers will soon advertise capabilities through a standardized discovery format. Treat your tool descriptions and server instructions as a search surface — documentation richness determines whether your server gets found.
What you’ve built — and what you haven’t
Built: a server that runs locally via stdio or deploys remotely over HTTP, connects to Claude, Cursor, Windsurf, Warp, and any MCP-compatible tool, uses Streamable HTTP transport, validates tokens before tools run, exposes resources for context, and returns structured errors.
Not covered: protection against tool poisoning from third-party servers you connect to. That’s a separate threat model. Audit external tool descriptions before trusting them.
This server follows the current spec, has proper auth, uses the right transport, and is documented well enough for an LLM to use it correctly.
That puts it ahead of 1,862 servers that had none of this.
Vinamra Yadav is a Software Engineer working at the intersection of Python, Go, and cloud infrastructure. He writes about the decisions that separate production-ready engineering from code that works until it doesn’t.
Join thousands of data leaders on the AI newsletter. Join over 80,000 subscribers and keep up to date with the latest developments in AI. From research to projects and ideas. If you are building an AI startup, an AI-related product, or a service, we invite you to consider becoming a sponsor.
Published via Towards AI
Towards AI Academy
We Build Enterprise-Grade AI. We'll Teach You to Master It Too.
15 engineers. 100,000+ students. Towards AI Academy teaches what actually survives production.
Start free — no commitment:
→ 6-Day Agentic AI Engineering Email Guide — one practical lesson per day
→ Agents Architecture Cheatsheet — 3 years of architecture decisions in 6 pages
Our courses:
→ AI Engineering Certification — 90+ lessons from project selection to deployed product. The most comprehensive practical LLM course out there.
→ Agent Engineering Course — Hands on with production agent architectures, memory, routing, and eval frameworks — built from real enterprise engagements.
→ AI for Work — Understand, evaluate, and apply AI for complex work tasks.
Note: Article content contains the views of the contributing authors and not Towards AI.