Guide · Runtimes
MCP server on Deno
Deno is a JavaScript/TypeScript runtime built by Node.js's original author that treats security as a first-class concern: your code cannot access the network, file system, or environment variables without explicit permission flags. For MCP servers, this security model is especially valuable — a compromised or buggy tool handler cannot exfiltrate secrets or modify files unless those permissions were explicitly granted at process start. Deno also runs TypeScript natively (no tsconfig required), imports npm packages with a npm: prefix, and deploys globally via Deno Deploy on V8 isolates at 35+ edge regions. This guide covers the practical differences from Node.js MCP development and the patterns specific to the Deno runtime.
TL;DR
Import the MCP SDK with npm:@modelcontextprotocol/sdk, declare required permissions explicitly in deno.json's tasks, and use Deno.serve() for the HTTP transport. The MCP SDK works without changes. The key Deno-isms to learn are: Deno.env.get() instead of process.env, URL-based imports instead of bare module names, and permission flags that must include every API surface your tools use. Monitor with AliveMCP the same way as any other MCP server — the runtime is transparent to the external protocol probe.
Installing and running the MCP SDK on Deno
Deno imports npm packages using the npm: prefix — no separate install step, no node_modules directory, no package.json required (though deno.json is recommended for import maps and task definitions):
// server.ts — minimal Deno MCP server
import { McpServer } from "npm:@modelcontextprotocol/sdk@latest/server/mcp.js";
import { StreamableHTTPServerTransport } from "npm:@modelcontextprotocol/sdk@latest/server/streamableHttp.js";
import { z } from "npm:zod@latest";
const server = new McpServer({ name: "deno-mcp-server", version: "1.0.0" });
server.tool(
"get_env",
"Get a non-sensitive environment variable",
{ key: z.string() },
async ({ key }) => {
// Deno.env.get() requires --allow-env or --allow-env=KEY1,KEY2
const value = Deno.env.get(key) ?? "(not set)";
return { content: [{ type: "text", text: value }] };
}
);
server.tool(
"read_file",
"Read a file from the allowed directory",
{ path: z.string() },
async ({ path }) => {
// Deno.readTextFile() requires --allow-read or --allow-read=/path/to/dir
const content = await Deno.readTextFile(path);
return { content: [{ type: "text", text: content }] };
}
);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID() });
await server.connect(transport);
// Deno.serve() is built-in — no Express needed
Deno.serve({ port: 8080 }, (req) => transport.handleRequest(req));
# Run with explicit permissions (principle of least privilege)
deno run \
--allow-net=localhost:8080,api.example.com \
--allow-env=API_KEY,DATABASE_URL \
--allow-read=/data \
server.ts
The permission flags scope exactly what your server can do. If a tool handler accidentally tries to access a resource not in the permission list, Deno throws a PermissionDenied error — caught by your tool's try/catch and returned as a structured error to the agent, rather than silently succeeding or failing in an unpredictable way.
deno.json: tasks, imports, and permissions
deno.json is the Deno equivalent of package.json for a server project. It defines tasks (equivalent to npm scripts) and import maps (pin version aliases):
{
"tasks": {
"dev": "deno run --watch --allow-net=localhost:8080 --allow-env --allow-read=./data server.ts",
"start": "deno run --allow-net=0.0.0.0:8080,api.example.com --allow-env=API_KEY,DATABASE_URL --allow-read=/data server.ts",
"test": "deno test --allow-net=localhost:8080 --allow-env tests/",
"check": "deno check server.ts"
},
"imports": {
"@modelcontextprotocol/sdk/": "npm:@modelcontextprotocol/sdk@1.0.0/",
"zod": "npm:zod@3.22.4"
},
"compilerOptions": {
"strict": true,
"lib": ["deno.window"]
}
}
With the import map in deno.json, your code can use bare specifiers like import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" — identical to Node.js imports. The import map pins the version; update the version in deno.json to upgrade, then run deno cache --reload server.ts to refresh.
| Permission flag | What it allows | Scope recommendation |
|---|---|---|
--allow-net | All outbound network connections | Specify hosts: --allow-net=api.example.com,0.0.0.0:8080 |
--allow-env | Read environment variables | Specify keys: --allow-env=API_KEY,PORT |
--allow-read | File system reads | Specify paths: --allow-read=/data,/etc/ssl |
--allow-write | File system writes | Specify paths: --allow-write=/data |
--allow-run | Spawn subprocesses | Specify commands: --allow-run=git,curl |
-A / --allow-all | Unrestricted access | Development only — never production |
Deno Deploy: global edge distribution
Deno Deploy runs your server on V8 isolates at 35+ regions worldwide (similar to Cloudflare Workers but with native Deno APIs instead of the Workers API). Deploy directly from a GitHub repository or with the deployctl CLI:
# Install deployctl
deno install -A --unstable -r https://deno.land/x/deploy/deployctl.ts
# Deploy from CLI
deployctl deploy --project=my-mcp-server --entrypoint=server.ts
# Or link a GitHub repository for automatic deploys on push
# (configured via dash.deno.com)
Deno Deploy has the same stateless-per-request model as Cloudflare Workers — no persistent in-memory state between requests. Use Deno KV (Deno's built-in globally distributed key-value store) for state that must persist across requests:
// Using Deno KV for session state in Deno Deploy
const kv = await Deno.openKv(); // Opens the deploy-managed KV store
server.tool(
"remember",
"Store a value for this session",
{ key: z.string(), value: z.string() },
async ({ key, value }) => {
const sessionId = "demo-session"; // Use real session ID in production
await kv.set(["sessions", sessionId, key], value, { expireIn: 3600 * 1000 });
return { content: [{ type: "text", text: `Stored: ${key}` }] };
}
);
server.tool(
"recall",
"Retrieve a stored session value",
{ key: z.string() },
async ({ key }) => {
const sessionId = "demo-session";
const entry = await kv.get<string>(["sessions", sessionId, key]);
return { content: [{ type: "text", text: entry.value ?? "(not found)" }] };
}
);
Deno KV in Deno Deploy replicates globally but is eventually consistent — a write in US East is visible in EU within ~100ms. For MCP servers where tool calls within a single session are sequential (the usual case), this is fine. For multi-agent scenarios where two agents write to the same session state concurrently, use atomic checks:
// Atomic update — fails if the key changed since last read
const result = await kv.atomic()
.check({ key: ["sessions", sessionId, "count"], versionstamp: currentVersionstamp })
.set(["sessions", sessionId, "count"], newCount)
.commit();
if (!result.ok) {
throw new Error("Concurrent modification — retry the operation");
}
Testing Deno MCP servers
Deno ships a built-in test runner (deno test) with the same assertion style as Node.js's built-in assert. Tests can use any permission subset — if a test doesn't need file access, don't grant it, and you'll catch permission scope creep early:
// tests/server_test.ts
import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { Client } from "npm:@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "npm:@modelcontextprotocol/sdk/client/streamableHttp.js";
const BASE_URL = "http://localhost:8080";
// Test setup: connect a real MCP client to the running test server
async function getTestClient(): Promise<Client> {
const client = new Client({ name: "test", version: "1.0.0" }, { capabilities: {} });
await client.connect(new StreamableHTTPClientTransport(new URL(BASE_URL)));
return client;
}
Deno.test("get_env returns value for allowed key", async () => {
const client = await getTestClient();
try {
const result = await client.callTool({ name: "get_env", arguments: { key: "DENO_DEPLOYMENT_ID" } });
assertEquals(result.content[0].type, "text");
} finally {
await client.close();
}
});
Deno.test("tools/list includes expected tools", async () => {
const client = await getTestClient();
try {
const { tools } = await client.listTools();
const names = tools.map(t => t.name);
assertEquals(names.includes("get_env"), true);
assertEquals(names.includes("read_file"), true);
} finally {
await client.close();
}
});
# Run tests with required permissions
deno test --allow-net=localhost:8080 --allow-env tests/
Monitoring Deno MCP servers
Deno's permission model has one monitoring-specific implication: if --allow-net does not include the network address your monitoring probe connects from, Deno will refuse the connection and return a permission error. On Deno Deploy this isn't an issue (Deploy allows inbound HTTP by default). For self-hosted Deno servers, ensure --allow-net=0.0.0.0:PORT or the specific IP of your server's listening address is in the permission flags — localhost:PORT alone allows binding but may not allow inbound connections from external IPs depending on the OS.
For Deno Deploy specifically, AliveMCP probes from outside the Deno Deploy edge network — the same external path your users take. Deno Deploy doesn't expose individual edge node health; it presents a single global endpoint. An AliveMCP monitor catches the classes of failures that affect the endpoint globally:
- A deploy that introduced a TypeScript runtime error (Deno surfaces type errors at runtime for some patterns even though it runs TypeScript natively)
- A Deno KV access failure that causes all tool handlers to throw
- A breaking npm package update that changed an API the MCP tools depend on
- A permission scope error if the deploy task's flags are narrower than the runtime needs
# Health check script for Deno MCP server (run in CI after deploy)
#!/bin/bash
set -e
ENDPOINT="https://my-mcp.deno.dev"
# Full MCP protocol probe
RESPONSE=$(curl -sf -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"ci-health-check","version":"1.0"}}}')
if ! echo "$RESPONSE" | grep -q '"protocolVersion"'; then
echo "FAIL: MCP initialize did not return protocolVersion"
echo "Response: $RESPONSE"
exit 1
fi
echo "OK: MCP protocol probe passed"
Frequently asked questions
Do I need a package.json or node_modules for Deno MCP development?
No. Deno resolves npm: specifiers from a global module cache ($DENO_DIR, typically ~/.cache/deno on Linux). The first time you run your server, Deno downloads and caches all imports. Subsequent runs use the cache. You can add a deno.json file to pin versions via import maps — this is recommended for production servers to prevent unexpected updates. If you're collaborating with a team, commit deno.json and run deno cache --lock=deno.lock server.ts to generate a lock file analogous to package-lock.json.
How do I handle Deno's permission model in production deployments?
Create a production task in deno.json with the exact minimum permissions your server needs. Run deno check server.ts (static type check) and then your server with a test request in a staging environment to verify no permission denials are thrown at runtime. The most common production permission mistake: forgetting --allow-env=VARIABLE_NAME for a secret that a tool reads. This causes the first call to that tool in production to fail with a PermissionDenied error. AliveMCP's protocol-level probe will show this as a successful HTTP response (the server runs) but a failed tool call — set up a tool-call test, not just an initialize check, to catch it.
What's the difference between Deno Deploy and self-hosted Deno?
Deno Deploy is a managed serverless platform: your code runs in V8 isolates at Deno's edge locations with no server management. It has the same stateless-per-request constraints as Cloudflare Workers — use Deno KV for persistent state. Self-hosted Deno runs as a regular long-lived process on your VPS or container, with the same operational model as a Node.js server — persistent in-memory state, SSE connections that survive beyond a single request, and process management via pm2 or systemd. For simple MCP servers, self-hosted is simpler. For globally distributed tools with the lowest latency, Deno Deploy is the better choice.
Can Deno MCP servers use WebSocket transport?
Yes — Deno has native WebSocket server support via Deno.upgradeWebSocket(). The MCP SDK's WebSocketServerTransport can be adapted to work with Deno's WebSocket API. However, Deno Deploy handles WebSocket connections with the same isolation model as HTTP requests — you'll need Deno KV or an external store to maintain state across WebSocket frames if your connection is routed to a different isolate between messages. For self-hosted Deno servers, WebSocket connections maintain state in the long-lived process, same as Node.js.
How does Deno handle TypeScript errors at runtime?
Deno type-checks TypeScript by default when you run deno check or use --check in the run command. Without --check, Deno transpiles TypeScript to JavaScript without type checking — runtime TypeScript errors (type assertions that fail, wrong types passed to tools) become runtime JavaScript errors. For production servers, run deno check server.ts in CI before deploying to catch type errors. Also note: Deno's TypeScript compiler may report errors for Node.js-specific types in npm packages (e.g., node:http types) — add "lib": ["deno.window"] to compilerOptions in deno.json to resolve most of these.
Further reading
- MCP server on Cloudflare Workers — V8 isolates and edge deployment
- MCP server with Bun — fast startup and native TypeScript
- MCP server secrets management — environment variables and secret stores
- MCP server transport selection — SSE vs StreamableHTTP vs stdio
- MCP server type safety — TypeScript patterns for tool schemas
- MCP server health checks — protocol probes and readiness verification
- AliveMCP — continuous protocol monitoring for MCP servers