Building MCP Servers with Deno: A Practical Guide
Why Deno is the right runtime for Model Context Protocol servers, the architecture I use, a working CalDAV example, and what I learned shipping four MCP servers in production.
Anton Shubin15 min read
The tool: caldav-mcpCode: github.com/spy4x/caldav-mcp (opens in a new tab)
Contents
TL;DR
- I write MCP servers in Deno: TypeScript runs natively, permission flags limit what the server can touch, and deno compile ships one binary.
- Start with Streamable HTTP, not stdio: every one of my four servers ended up needing HTTP anyway.
- Give each tool a context object instead of globals, and design it from its Zod schema first; both keep tools small and testable.
- Ship the minimum first, then add stderr logging, error boundaries, timeouts and a health check as production shows you what you need.
Model Context Protocol (MCP) is the JSON-RPC 2.0 standard that lets AI assistants (Claude, Open WebUI, Cursor, OpenCode) call tools you expose. The official SDKs are TypeScript-first, and Deno is the most ergonomic runtime to build with: zero npm install, a single binary, and a security model that actually makes sense.
This is the architecture I use for every MCP server I ship. The example is a working CalDAV MCP — the same code that powers my own setup.
Why Deno for MCP servers
Link to section: Why Deno for MCP serversI write MCP servers in Deno, not Node, for six concrete reasons:
- Zero npm install. MCP servers are small, single-purpose tools. The
dependency tree of a typical Node MCP is 200MB+ of
node_modulesfor what should be a small program. Deno imports URLs directly — the entire server is a single binary you candeno compile. - Built-in TypeScript. No
tsconfig.json, nots-node, notsup. Deno runs.tsfiles natively. Tooling stays out of the way. - Permission flags. MCP servers run unattended in production. Deno's
--allow-net,--allow-read,--allow-envflags mean the server literally cannot read a file you did not whitelist. Node has no equivalent. Deno.serveinstead of Express. The standard library ships a high-performance HTTP server. You do not need Express, Hono, or Fastify for most MCP use cases — a 30-lineDeno.servehandler is enough.- The MCP TypeScript SDK works. The official
@modelcontextprotocol/sdkis published to npm and works fine in Deno vianpm:specifiers. - Single binary deploy.
deno compile --output mcp-server main.tsproduces a self-contained Linux binary. No runtime to install on the server, no node_modules to copy, nopackage.jsonto maintain.
The shape of an MCP server
Link to section: The shape of an MCP serverEvery MCP server I write has the same four files:
mcp-server/
├── deno.jsonc # tasks + import map
├── main.ts # entry point — transport + routing
├── mcp.ts # tool registration + handlers
└── tools/ # one file per tool
├── list_calendars.ts
├── create_todo.ts
└── ...
The split is intentional. Tools are self-contained — each file exports a single async function. Adding a tool is "write one file, register one line". Removing a tool is the inverse. There is no central god-object.
The transport: HTTP, not stdio
Link to section: The transport: HTTP, not stdioThe MCP spec defines two transports: stdio (for local clients like Claude Desktop) and HTTP (for remote clients). Most existing MCP servers are stdio-only because the official SDK shipped stdio first.
I default to Streamable HTTP for every server I write. Reasons:
- Open WebUI requires HTTP. The Open WebUI
TOOL_SERVER_CONNECTIONSenv var accepts HTTP MCP servers natively. Stdio MCP servers need anmcpoproxy in between — extra container, extra latency, extra failure point. - Production-friendly. An HTTP server can be load-balanced, monitored, and rolled. A stdio server is a process spawned by another process — debugging it is painful.
- Bearer token auth is trivial. Add a middleware. Stdio auth is process-environment-only and a footgun.
The cost is small: ~50 lines of HTTP transport code. Worth it.
A working example: list_calendars
Link to section: A working example: list_calendarsLet me walk through a real tool I shipped in caldav-mcp. The user calls
list_calendars to see which calendars exist on their Radicale/Nextcloud
server.
The CalDAV client (already on the host)
Link to section: The CalDAV client (already on the host)// caldav/client.ts
export async function propfind(
url: string,
depth: "0" | "1",
auth: { user: string; password: string },
): Promise<string> {
const body = `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:resourcetype/>
</d:prop>
</d:propfind>`;
const res = await fetch(url, {
method: "PROPFIND",
headers: {
"Depth": depth,
"Content-Type": "application/xml",
"Authorization": `Basic ${btoa(`${auth.user}:${auth.password}`)}`,
},
body,
});
if (!res.ok) throw new Error(`PROPFIND ${url}: ${res.status}`);
return await res.text();
}
Standard CalDAV PROPFIND. Three things to note:
- No library. This is 30 lines. There is a popular npm library (
tsdav) that does this in 200 lines, but it has unfixed bugs and ships its own XML parser. Directfetchis shorter and works. - Basic auth in code. The
Authorizationheader is constructed from the user/pass the MCP server was started with. The MCP never stores credentials — they come from env vars and live only in process memory. - Errors are structured.
throw new Errorwith the URL and status. The MCP layer catches and surfaces them as JSON-RPC error responses.
The tool wrapper
Link to section: The tool wrapper// tools/list_calendars.ts
import { z } from "npm:zod@3.23.8";
import { propfind } from "../caldav/client.ts";
export const listCalendarsInput = z.object({});
export type ListCalendarsInput = z.infer<typeof listCalendarsInput>;
export async function listCalendars(
_input: ListCalendarsInput,
ctx: { baseUrl: string; auth: { user: string; password: string } },
): Promise<{ calendars: Array<{ name: string; url: string }> }> {
const xml = await propfind(ctx.baseUrl, "1", ctx.auth);
// Parse multistatus response, extract displayname + href
const calendars = parseMultistatus(xml);
return { calendars };
}
Three things to note:
- Zod for input validation. Every tool has a Zod schema. The MCP SDK auto-derives the JSON schema for the AI assistant from the Zod schema. The AI sees a typed tool, you get free runtime validation.
- Context is passed explicitly. The tool function does not read globals or
env vars. It takes a
ctxobject. This makes tools trivially testable — pass a fakectx, assert on the return. - No logging in the tool. The tool returns data. The MCP layer logs. Mixing the two is the standard way MCP servers become undebuggable.
The tool registration
Link to section: The tool registration// mcp.ts
import { Server } from "npm:@modelcontextprotocol/sdk@1.0/server/index.js";
import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk@1.0/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "npm:@modelcontextprotocol/sdk@1.0/types.js";
import { listCalendars, listCalendarsInput } from "./tools/list_calendars.ts";
const server = new Server(
{ name: "caldav-mcp", version: "0.1.0" },
{ capabilities: { tools: {} } },
);
// Tool registry: name → { schema, handler }
const tools = {
list_calendars: { schema: listCalendarsInput, handler: listCalendars },
} as const;
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: Object.entries(tools).map(([name, { schema }]) => ({
name,
description: `…`, // human-readable
inputSchema: zodToJsonSchema(schema),
})),
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
const tool = tools[req.params.name as keyof typeof tools];
if (!tool) throw new Error(`Unknown tool: ${req.params.name}`);
const args = tool.schema.parse(req.params.arguments ?? {});
const ctx = {
baseUrl: Deno.env.get("CALDAV_URL")!,
auth: {
user: Deno.env.get("CALDAV_USERNAME")!,
password: Deno.env.get("CALDAV_PASSWORD")!,
},
};
const result = await tool.handler(args, ctx);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
});
Two patterns worth stealing:
- The tool registry is a typed object.
toolshasas constso TypeScript can checkreq.params.nameagainst the actual registered tools. Adding a tool that is referenced but not registered (or vice versa) is a compile error, not a runtime crash. - The context is built once per request. It is cheap to construct (just env reads), and it keeps the tool functions pure.
The transport
Link to section: The transport// main.ts — stdio transport (simplest case)
import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk@1.0/server/stdio.js";
import { server } from "./mcp.ts";
const transport = new StdioServerTransport();
await server.connect(transport);
For HTTP, swap in StreamableHTTPServerTransport and a Deno.serve handler.
The MCP SDK provides both. The full HTTP version is ~50 lines and the only real
difference is wrapping the request/response in JSON-RPC envelopes with proper
headers.
Production hardening
Link to section: Production hardeningThe "hello world" version is 80 lines. The production version stays a small program overall, but grows noticeably because of the boring work that makes it survive a real environment.
1. Structured logging
Link to section: 1. Structured logging// log.ts
export function log(
level: "info" | "warn" | "error",
msg: string,
ctx?: unknown,
) {
const line = JSON.stringify({
ts: new Date().toISOString(),
level,
msg,
ctx,
});
console.error(line); // stderr — keep stdout clean for JSON-RPC
}
Use console.error for logs, never console.log. The MCP protocol uses stdout.
A log line on stdout is a malformed JSON-RPC frame and the client crashes.
2. Per-tool error boundaries
Link to section: 2. Per-tool error boundariesserver.setRequestHandler(CallToolRequestSchema, async (req) => {
try {
// … existing handler
} catch (err) {
log("error", "tool failed", { tool: req.params.name, err: String(err) });
return {
isError: true,
content: [{ type: "text", text: `Error: ${(err as Error).message}` }],
};
}
});
The AI gets a structured error message. The user sees a clear "tool failed:
3. Rate limiting and timeouts
Link to section: 3. Rate limiting and timeoutsEvery upstream call needs a timeout. CalDAV servers hang, IMAP servers hang, SMTP servers hang. Default is 30 seconds; tighter for read-only tools (5s), looser for sends (60s).
async function withTimeout<T>(
p: Promise<T>,
ms: number,
label: string,
): Promise<T> {
return await Promise.race([
p,
new Promise<T>((_, reject) =>
setTimeout(
() => reject(new Error(`${label} timed out after ${ms}ms`)),
ms,
)
),
]);
}
Wrap every tool call. Never let the LLM wait 5 minutes for a response — it will retry, multiplying the load.
4. Health check
Link to section: 4. Health checkAdd a GET /health route that returns { status: "ok" }. Your container
orchestrator can probe it. The MCP SDK does not give you this for free — you
wire it into the same Deno.serve handler.
The deploy
Link to section: The deploy# Compile to a single binary
deno compile --allow-net --allow-env --output caldav-mcp main.ts
# Run
CALDAV_URL=https://radicale.example.com/ \
CALDAV_USERNAME=spy4x \
CALDAV_PASSWORD=*** \
./caldav-mcp
That is the whole deploy story. One binary, three env vars, no package.json,
no node_modules, no Dockerfile required (though I ship a Dockerfile for the
homelab — ~10 lines, multi-stage).
What I learned shipping four of these
Link to section: What I learned shipping four of theseI now have four MCP servers running in production (caldav-mcp, immich-mcp, email-mcp, google-maps-mcp). The lessons that surprised me:
- Streamable HTTP wins for everything. I started with stdio "for simplicity." Every single one had to be HTTP anyway. Just start there.
- Per-tool context beats globals. The first version of caldav-mcp read env vars inside each tool. Adding tests meant refactoring every tool. The ctx-object pattern fixed it once.
- Schema-first tool design. I design tools by writing the Zod schema first. The schema is the API. Once it is right, the implementation is mechanical.
- The LLM is the user, not a developer. Tools should be verbose in their
results.
textcontent with a short, human-readable summary plus structured JSON inside is the right shape. Do not expect the LLM to do clever work on terse responses. - Ship in 80 lines, harden in 200. Trying to write the production version from scratch is a mistake. Write the minimum, deploy it, then add the boring work (logging, timeouts, error handling) as the production traffic shows you what you need.
If you are building an MCP server, start with the MCP TypeScript SDK docs and the caldav-mcp source as a working reference. The pattern is the same regardless of which protocol you wrap.
Need this for your product?
Anton Shubin
Senior Full-Stack Engineer & Tech Lead
I build greenfield SaaS on a modern, lightweight stack, alone or with a team of senior developers from my own pool, and I cover full-stack, DevOps and architecture.
Get the next post by email
New posts on decisions for founders, AI and MCP, and self-hosting, about once a week at most, and only when there is one. Unsubscribe with one click.
