The Model Context Protocol went from ~2M monthly SDK downloads at launch in November 2024 to 97M/month by March 2026. The public registry grew from 1,200 servers in Q1 2025 to 9,400+ by April. It's now the de facto standard for connecting LLMs to external tools, files, and APIs.
The tutorials haven't caught up. Almost every public MCP example shows you one transport, one tool, no tests, and no auth. That's fine for a hello-world. It's not fine for a server you'd actually let an AI agent talk to.
Here are the five gotchas I hit while shipping MCP servers for client work, with the patterns I now use everywhere.
1. The SDK doesn't validate tool inputs for you
If you register a tool with a Zod schema, you'd assume the SDK enforces it on every invocation. It doesn't — the schema is metadata for the client. The handler still receives whatever the model decides to send.
Validate explicitly inside every handler:
const ReadFileInputSchema = z.object({
path: z.string().min(1),
encoding: z.enum(["utf8", "base64"]).default("utf8"),
max_chars: z.number().int().min(1).max(500000).default(50000),
});
server.tool("read_file", "Reads a local file...", ReadFileInputSchema.shape,
async (input) => {
const { path: filePath, encoding, max_chars } =
ReadFileInputSchema.parse(input); // <-- this line is the one
// ... handler logic
}
);
parse() throws if the input is malformed. Catch it at the top of the handler and return a structured MCP error. Skip this and the model can hand you null, a wrong type, or an injected key.
2. Path traversal is the first thing to test, not the last
If your file tool accepts "../../../etc/passwd", your AI assistant just became an exfiltration vector. I see this bug in roughly half the tutorial code I read.
Don't sandbox with a regex. Use the filesystem's own path resolver:
function resolveSafePath(filePath: string): string {
const base = path.resolve(getAllowedBase());
const resolved = path.resolve(base, filePath);
if (!resolved.startsWith(base + path.sep) && resolved !== base) {
throw new ValidationError(
`Path "${filePath}" escapes the allowed base directory.`
);
}
return resolved;
}
The base + path.sep matters. startsWith(base) alone lets /tmp/sandbox-evil/secret.txt pass when your base is /tmp/sandbox. Add the separator. Write a test that passes .., ../.., /etc/passwd, and a symlink before you ship.
3. Pick both transports, not one
stdio is what Claude Desktop and Cursor want. Streamable HTTP/SSE is what your production deploy wants (Railway, Cloudflare Workers, Fly). Most kits make you pick.
The right shape is one tool registry, two transport entry points:
const transport = process.env.MCP_TRANSPORT === "http"
? createHttpTransport({ port: Number(process.env.PORT ?? 3000) })
: createStdioTransport();
const server = new McpServer({ name: "my-server", version: "1.0.0" });
registerAllTools(server);
await server.connect(transport);
The tools don't know or care which transport is underneath them. Local dev with stdio in Claude Desktop, deploy the same code with MCP_TRANSPORT=http.
4. OAuth 2.1 is not optional for HTTP transport
The MCP spec moved to OAuth 2.1 Bearer tokens for remote servers. If you're deploying over HTTP and not validating tokens, anyone with your URL can call your tools.
Middleware shape:
async function bearerAuth(req, res, next) {
const header = req.headers.authorization ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token || !(await tokenStore.verify(token))) {
res.statusCode = 401;
return res.end(JSON.stringify({ error: "invalid_token" }));
}
next();
}
Make tokenStore an interface so you can swap a static token for dev, JWT verification for staging, and a real authorization server for prod without touching the handler code.
5. The error envelope matters more than the happy path
Models retry on errors. If your error response is unstructured prose, the model will keep retrying the same broken call. Return errors as MCP content blocks with isError: true and a short, machine-readable reason. The model will adjust its next call.
Most tutorials throw raw exceptions. Don't.
TL;DR
These five things — input validation, path safety, dual transport, OAuth, structured errors — are what take an MCP demo to an MCP server you'd let an agent talk to in production. They're each 20–50 lines of code. They each take an afternoon to get right the first time. And they each have a subtle wrong way that the tutorials happily teach.
If you want the full scaffold with all five wired up correctly — including Zod schemas, the path-safe sandbox, both transports, OAuth middleware, 20 Vitest tests, and Railway/Cloudflare/Docker deploy configs — I packaged it as a starter kit: MCP Server Starter Kit on Gumroad. v1.x updates are free as the protocol evolves.
Either way, the patterns above are yours. Steal them. Ship safely.
Top comments (0)