Project: Deno MCP server
Start with stdio and read-only tools, validate arguments with Zod, protocol-test with fixtures, then expand to Streamable HTTP
This tutorial expands blueprint 2 into concrete steps. For the registerTool, transport, and client-configuration API walkthrough see Building MCP servers with Deno; this page focuses on project structure, testing, and the evolution path.
Goal and final shape
mcp-project/
├── main.ts # entrypoint: assemble the server, connect a transport
├── tools/{search,read}.ts # read-only tools, one module each
├── schemas.ts # Zod argument validation, single source of truth
├── fixtures/ # JSON-RPC request/response samples for protocol tests
└── deno.json
Phase one: stdio transport plus read-only tools, launched by clients with least privilege. Phase two: fixture-driven protocol tests. Only then evaluate Streamable HTTP, authentication, and write operations — each is an independently acceptable slice.
Setup
deno init mcp-project
cd mcp-project
deno add npm:@modelcontextprotocol/sdk npm:zod
Milestone 1: stdio + read-only tools
Centralize validation in schemas.ts
import { z } from "zod";
export const noteName = z.string().regex(/^[a-z0-9-]+$/)
.describe("Short name of an approved note");
export const searchQuery = z.string().min(1).max(200);
Implement tools as independently testable functions
Separate business logic from protocol registration: the handler core is a plain async function, and registerTool only does the wiring.
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { noteName } from "../schemas.ts";
export async function readNote(name: string): Promise<string> {
return await Deno.readTextFile(`./notes/${name}.md`);
}
export function registerReadTool(server: McpServer) {
server.registerTool(
"read_project_note",
{
description: "Read one approved note by its short name",
inputSchema: { name: noteName },
},
async ({ name }) => ({
content: [{ type: "text", text: await readNote(name) }],
}),
);
}
tools/search.ts follows the same shape: a bounded search over an allowed directory, results truncated to a fixed count. Note the path safety story — the slug allowlist regex (^[a-z0-9-]+$) rejects ../ and friends at the Zod layer, so no additional path-joining defense is needed.
Entrypoint
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerReadTool } from "./tools/read.ts";
import { registerSearchTool } from "./tools/search.ts";
const server = new McpServer({ name: "project-notes", version: "0.1.0" });
registerReadTool(server);
registerSearchTool(server);
await server.connect(new StdioServerTransport());
The client configuration stays least-privilege — read access to the notes directory only:
{
"mcpServers": {
"project-notes": {
"command": "deno",
"args": ["run", "--allow-read=./notes", "main.ts"]
}
}
}
Milestone 2: fixture-based protocol tests
The stdio transport frames messages as newline-delimited JSON-RPC, so an entire session can be stored as a text fixture and replayed through a subprocess:
fixtures/
├── initialize.jsonl # initialize + notifications/initialized
├── tools-list.jsonl
├── tools-call-read.jsonl
└── tools-call-invalid.jsonl # bad arguments, expecting an error response
The test spawns the real server, writes a fixture to its stdin, and asserts on key response fields (result.tools[].name, error.code, ...) rather than byte-for-byte output — fields like timestamps drift:
import { assertEquals } from "jsr:@std/assert";
async function runFixture(fixture: string): Promise<unknown[]> {
const proc = new Deno.Command("deno", {
args: ["run", "--allow-read=./notes", "main.ts"],
stdin: "piped",
stdout: "piped",
}).spawn();
const writer = proc.stdin.getWriter();
await writer.write(await Deno.readFile(`fixtures/${fixture}`));
await writer.close();
const { stdout } = await proc.output();
return new TextDecoder().decode(stdout)
.trim().split("\n").map((line) => JSON.parse(line));
}
Deno.test("tools/list exposes only read-only tools", async () => {
const responses = await runFixture("tools-list.jsonl");
const result = responses.find((r) => (r as { result?: unknown }).result);
const tools = (result as { result: { tools: { name: string }[] } })
.result.tools.map((t) => t.name);
assertEquals(tools.sort(), ["read_project_note", "search_notes"]);
});
The parent test process and the child server have two separate permission sets: the parent needs to read fixtures and spawn the subprocess, while the child server keeps least privilege. Encode the protocol-test permissions in a task instead of papering over them with -A:
{
"tasks": {
"test": "deno test --allow-read=fixtures --allow-run=deno",
"test:unit": "deno test --allow-read=./notes tools/ schemas_test.ts"
}
}
Add plain unit tests at the tool-function layer: readNote returns content for valid names and throws for missing ones. Test the Zod schemas directly with noteName.safeParse("../etc") asserting failure.
Milestone 3 (optional): Streamable HTTP, auth, writes
Do this only when remote clients actually need it, and in this order:
- Streamable HTTP transport: the SDK ships
StreamableHTTPServerTransport; the older HTTP+SSE transport is for legacy compatibility only. - Authentication and hardening: a public deployment needs authentication, Origin/DNS-rebinding protection, rate limiting, and per-call authorization — the background for these requirements is in the MCP server guide.
- Write operations: write tools get their own permission boundary (a dedicated
--allow-writedirectory), argument allowlists, and audit logging; replay hostile inputs (path traversal, oversized arguments, concurrent calls) through fixtures before opening them up.
Test strategy summary
- Unit tests: tool core functions plus Zod schemas, no server involved.
- Protocol tests: fixtures through a real stdio subprocess, covering initialize, tools/list, and tools/call success and error branches.
- Permission tests: deliberately make a tool reach outside
./notesand assert Deno's permission layer refuses (this validates the least-privilege configuration itself).
Deployment
- stdio distribution: users run
deno runlocally; there is no server to host. Commitdeno.lockand document the client configuration snippet in the README. - Remote deployment: with Streamable HTTP, deploy to Deno Deploy; manage environment variables through the platform secret store and follow the production baseline checklist.
Acceptance checklist
- Client configuration grants only least-privilege flags such as --allow-read=./notes
- All tool arguments pass Zod validation; invalid input yields a protocol error, not a crash
- Fixture protocol tests cover success and error branches of initialize, tools/list, and tools/call
- No debug output on stdout; all logs go to stderr
- Tool core logic has no protocol dependency and is unit-testable without the server
- deno fmt --check, deno lint, deno check, and deno test all pass
Official references: Deno MCP server example, MCP TypeScript SDK, Model Context Protocol.