DEV Community

Atlas Forge
Atlas Forge

Posted on

I built 10 MCP server templates so you don't have to write the same boilerplate I did

I've been building MCP servers since the spec was announced. After writing the same transport setup, error handling, and tool registration code for the fifth time, I extracted it into templates. This is what I learned.

The spec is simple. The plumbing isn't.

MCP's core concept is straightforward: a server exposes tools, a client calls them. The JSON-RPC protocol is clean. But every server needs the same surrounding infrastructure:

  • Transport setup (stdio, SSE, or Streamable HTTP)
  • Input validation with proper error responses
  • Structured logging that doesn't break the protocol
  • Graceful shutdown handling
  • Client configuration files for Claude Desktop, Cursor, etc.

None of this is hard. It's just tedious. And it's the same every time.

stdio is the right default

Most MCP servers should use stdio transport. Here's why:

SSE adds HTTP server complexity, connection management, and CORS. You need a running server, a port, and a URL. For a tool that reads files or queries a database, that's overhead with no benefit.

stdio just works. The client spawns your process, communicates over stdin/stdout, and kills it when done. No ports, no CORS, no server lifecycle. Claude Desktop and Cursor both support it natively.

const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

That's it. No HTTP server, no port management, no health checks.

I see people reaching for SSE too early because "I might want to deploy this remotely someday." You probably won't. And if you do, the transport swap is a 10-line change.

Zod validation is non-negotiable

The MCP SDK lets you define tool schemas as plain JSON. Don't. Use Zod:

import { z } from "zod";

const querySchema = z.object({
  sql: z.string().refine(
    (s) => !s.match(/\b(insert|update|delete|drop|alter|create|truncate)\b/i),
    "Only SELECT queries are allowed"
  ),
  limit: z.number().min(1).max(100).default(50),
});
Enter fullscreen mode Exit fullscreen mode

This gives you:

  • Runtime validation (reject bad input before it hits your logic)
  • TypeScript inference (no manual interface definitions)
  • Human-readable error messages that get passed back to the LLM

Without Zod, you're either writing manual validation (verbose, error-prone) or skipping it (dangerous — the LLM will send you garbage eventually).

The database template taught me about read-only enforcement

My database query template has a read-only guard. The first version checked the SQL string for forbidden keywords. The LLM bypassed it in 30 seconds with a CTE:

WITH delete_me AS (DELETE FROM users RETURNING *) SELECT * FROM delete_me;
Enter fullscreen mode Exit fullscreen mode

The fix wasn't better regex. The fix was using the database's own permissions:

// Create a read-only role and connect with it
const client = new Client({ connectionString: readOnlyConnectionString });
Enter fullscreen mode Exit fullscreen mode

The application-level check is still there as a fast path, but the database role is the actual security boundary. If the LLM finds a bypass, the database still rejects the write.

This is the pattern: defense in depth. Application checks for UX, database checks for security.

Python's async story is different

The Python MCP SDK is async. The TypeScript SDK is not (it uses callbacks). This means:

In TypeScript, tool handlers are synchronous functions that return a result. Simple.

In Python, tool handlers are coroutines. You need to think about:

  • Blocking calls (use asyncio.to_thread for sync DB drivers)
  • Cancellation (the client can cancel mid-flight)
  • Resource cleanup (use async context managers)
@mcp.tool()
async def query(sql: str) -> str:
    async with pool.acquire() as conn:
        result = await conn.fetch(sql)
        return json.dumps([dict(r) for r in result])
Enter fullscreen mode Exit fullscreen mode

The async pool is important. If you create a new connection per request, you'll exhaust the connection pool under load. If you share a single connection, concurrent requests will serialize. A pool gives you both concurrency and reuse.

Streaming is harder than it looks

My streaming server template uses SSE transport. The tricky part isn't the transport — it's backpressure.

When a tool produces output incrementally (e.g., processing a large file), you want to stream results to the client. But the client might be slow. If you're pushing data faster than the client consumes it, you need backpressure.

The MCP SDK doesn't handle this for you. You need to:

  1. Check if the client is still connected before sending
  2. Use a bounded queue for outgoing messages
  3. Drop or batch if the queue is full

I learned this the hard way when a streaming tool caused an OOM in production because the client was slower than the producer.

Client configuration is the last mile

The most overlooked part of MCP server development is the client config. Your server works, but the user needs to tell Claude Desktop where to find it.

Every template I ship includes a client-configs/ directory with ready-to-paste JSON for Claude Desktop, Cursor, and Windsurf:

{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/absolute/path/to/dist/index.js"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The absolute path matters. Relative paths don't work in Claude Desktop's config. This trips up every new MCP developer.

What I'd do differently

If I were starting the templates over:

  1. Fewer templates, more depth. 10 templates is a lot to maintain. I'd rather have 5 that are battle-tested than 10 that are 80% done.

  2. Tests in every template. The hello-world template has tests. The others don't. That's a gap. Every template should have at least one integration test that verifies the tool actually works.

  3. Docker-first. I added Docker configs to every template, but the default instructions still say "npm install && npm run dev." Docker should be the primary path. It eliminates the "works on my machine" problem entirely.

  4. Better error messages. Most templates return generic errors. The LLM can't debug from "Error: something went wrong." Errors should include what the tool tried, what it expected, and what it got.


The templates are on GitHub: thenextfreud/agentforge. MIT licensed. If you build something with them, I'd like to hear about it.

Top comments (0)