{"slug": "the-parts-of-building-an-mcp-server-that-the-tutorials-skip", "title": "The parts of building an MCP server that the tutorials skip", "summary": "A developer building an MCP server found that 52% of 2,181 remote MCP endpoints scanned in April 2026 were completely dead, with only about 9% fully healthy. The failures stem from issues around the protocol, such as logging to stdout, untyped errors, SSRF vulnerabilities, and authentication problems. The developer provides fixes including logging to stderr, typed error codes, host allowlists, and proper bearer token auth.", "body_md": "Building your first [Model Context Protocol](https://modelcontextprotocol.io) server takes about twenty minutes. The official quickstart is genuinely good: `npm install`\n\n, register a tool, connect a stdio transport, and Claude or Cursor can call your code. Hello, world.\n\nThen you try to make it something other people can actually use, and you fall off a cliff.\n\nI know the cliff is real because someone measured it. An April 2026 scan of 2,181 remote MCP endpoints found **52% of them completely dead**, and only about 9% fully healthy. These aren't abandoned toys — they're servers people shipped and expected to work. They didn't die from protocol bugs. The protocol is the easy part. They died from everything *around* the protocol, which is exactly what the tutorials skip.\n\nHere are the parts that actually matter, and how I handle each. There's a small MIT-licensed starter kit at the bottom that ships all of it, but the ideas are portable to any stack.\n\n`stdout`\n\nis not yours\nThe first one bites everybody. On the stdio transport, **stdout is the JSON-RPC channel**. A single stray `console.log`\n\n— yours, or a dependency's — injects a line into the protocol stream, and the client dies with a cryptic JSON parse error that points nowhere near the actual `console.log`\n\n.\n\nThe fix is one word: log to `stderr`\n\n.\n\n```\n// stdout is the protocol channel on stdio. Log to stderr ONLY.\nconsole.error(`${SERVER_INFO.name} v${SERVER_INFO.version} ready on stdio`);\n```\n\nThat's it. But you have to *know* it, and no quickstart tells you, because the quickstart never logs anything.\n\nWhen a tool throws, the default experience is terrible: the agent sees `internal error`\n\nand cannot tell an auth failure from a bad argument from an upstream 500. It can't decide whether to retry, fix its input, or give up — so it often retries a doomed call in a loop and burns your API budget overnight. That \"quiet retry\" is one of the most-cited ways agents rack up cost in production.\n\nThe fix is a typed error that carries a machine-readable code, and a wrapper that turns a throw into a proper MCP error result instead of a dropped connection:\n\n```\nexport class ToolError extends Error {\n  constructor(public readonly code: ToolErrorCode, message: string) {\n    super(message);\n  }\n}\n\n// every handler funnels through this:\nexport function toToolResult(err: unknown) {\n  const e = err instanceof ToolError ? err : new ToolError(\"internal\", String(err));\n  return { isError: true as const, content: [{ type: \"text\", text: `[${e.code}] ${e.message}` }] };\n}\n```\n\nNow the agent sees `[forbidden_host] Host \"x\" is not in ALLOWED_FETCH_HOSTS`\n\nand can actually reason about it. Legibility is a feature you build for the *model*, not just the human reading logs.\n\nThe moment you write a tool that takes a URL and fetches it, you've built a potential SSRF proxy: an agent (or a prompt-injected one) can point it at `http://169.254.169.254/`\n\nor your internal admin panel and read the response. A fetch tool without a host allowlist is a security incident waiting for a trigger.\n\nSo the example fetch tool refuses anything it wasn't told to allow, plus enforces https and a hard timeout:\n\n```\nif (parsed.protocol !== \"https:\") throw new ToolError(\"invalid_input\", \"https only\");\nif (!allowedHosts().includes(parsed.hostname))\n  throw new ToolError(\"forbidden_host\", `${parsed.hostname} is not allowlisted (SSRF guard).`);\n\nconst controller = new AbortController();\nconst timer = setTimeout(() => controller.abort(), timeout); // no unbounded hangs\n```\n\nThree guards, none of which the \"here's a tool that calls an API\" tutorial includes.\n\nSurvey data from 2026 is blunt: OAuth is the single biggest blocker for production MCP servers, over half of remote servers fall back to static keys, and OAuth failures tend to be *silent* — the hardest kind to debug.\n\nYou don't need full OAuth to get remote-safe. You need auth that **fails closed** and tells you why. Bearer tokens, done properly, are the right first step:\n\n```\nif (tokens.length === 0) {\n  // An HTTP MCP server with auth off is the default that gets scraped. Refuse to run open.\n  return reject(\"No MCP_BEARER_TOKENS configured; refusing all requests.\");\n}\n// ...constant-time compare, real 401 + WWW-Authenticate header, never log the token.\n```\n\nThe important word is *closed*. If you forget to configure tokens, the server rejects everything rather than quietly serving your tools to the internet. Full delegated OAuth 2.1 — per-user scopes, token exchange, refresh — is a real, larger job for public multi-tenant servers; the mistake is pretending a hand-rolled flow is that, or shipping with auth off \"for now.\"\n\nHere's the subtle one behind a lot of those 52%-dead endpoints. MCP's streamable HTTP transport can hold session state in process memory. Deploy that to anything that scales to zero or spreads load across instances — Lambda, Cloud Run, Fly, Workers — and the follow-up request lands on a **cold instance that never saw the session**. The client hangs. No error, no log, just dead.\n\nThe cheap fix is to not hold session state at all: build a fresh server and transport per request.\n\n``` js\napp.post(\"/mcp\", bearerAuth, async (req, res) => {\n  const server = buildServer();\n  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // stateless\n  res.on(\"close\", () => { transport.close(); server.close(); });\n  await server.connect(transport);\n  await transport.handleRequest(req, res, req.body);\n});\n```\n\nIf you genuinely need cross-request state later, back it with Redis or a durable object keyed by session id — never a module variable. But start stateless. It's the shape that survives the platform you'll actually deploy on.\n\nThe last habit is the cheapest: when people do write tests for MCP tools, they test the demo — call the tool, assert the happy result. But every section above describes a *guard*, and a guard you haven't tested is a guard you don't have. The tests that earn their keep assert that the server **refuses** correctly:\n\n``` js\nit(\"rejects a host that is not allowlisted (SSRF guard)\", async () => {\n  await expect(\n    httpGetJson({ url: \"https://evil.example.com/x\" }),\n  ).rejects.toMatchObject({ code: \"forbidden_host\" });\n});\n\nit(\"rejects non-https URLs\", async () => {\n  await expect(\n    httpGetJson({ url: \"http://api.github.com/x\" }),\n  ).rejects.toMatchObject({ code: \"invalid_input\" });\n});\n```\n\nNotice what's being asserted: not just that it throws, but that it throws with the *right machine-readable code* — because that code is the contract from section 2, the thing the agent reasons about. If a refactor ever turns `forbidden_host`\n\ninto a generic `internal`\n\n, the type checker won't care, the happy-path test won't care, and your agent quietly loses the ability to tell \"blocked by policy\" from \"server bug.\" This test is the only thing standing there.\n\nSame principle for auth: the test worth writing is the one where **no token is configured** and the server refuses everything. Fail-closed is a behavior; pin it.\n\nNone of this is hard once you've named it. That's the whole point: the failure mode isn't difficulty, it's *invisibility* — every one of these is missing from the tutorials, so everyone rediscovers them the same way, in production, from a hanging client.\n\nI packaged all six into a small, MIT-licensed TypeScript starter — stdio + streamable HTTP, the fail-closed bearer auth, the legible `ToolError`\n\n, the SSRF-guarded example tool, the refusal tests, and a `DEPLOYMENT.md`\n\nthat walks the cold-start problem. It builds, it's tested, and it's meant to be read top to bottom in one sitting:\n\n**→ https://github.com/park11innyc-lgtm/mcp-server-starter-kit**\n\nClone it, delete what you don't need, and skip the cliff.", "url": "https://wpnews.pro/news/the-parts-of-building-an-mcp-server-that-the-tutorials-skip", "canonical_source": "https://dev.to/parkerrrrr/the-parts-of-building-an-mcp-server-that-the-tutorials-skip-3n07", "published_at": "2026-07-29 02:08:23+00:00", "updated_at": "2026-07-29 02:31:50.246684+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-safety", "ai-infrastructure"], "entities": ["Model Context Protocol", "Claude", "Cursor"], "alternates": {"html": "https://wpnews.pro/news/the-parts-of-building-an-mcp-server-that-the-tutorials-skip", "markdown": "https://wpnews.pro/news/the-parts-of-building-an-mcp-server-that-the-tutorials-skip.md", "text": "https://wpnews.pro/news/the-parts-of-building-an-mcp-server-that-the-tutorials-skip.txt", "jsonld": "https://wpnews.pro/news/the-parts-of-building-an-mcp-server-that-the-tutorials-skip.jsonld"}}