DocsProject blueprints

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:

  1. Streamable HTTP transport: the SDK ships StreamableHTTPServerTransport; the older HTTP+SSE transport is for legacy compatibility only.
  2. 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.
  3. Write operations: write tools get their own permission boundary (a dedicated --allow-write directory), 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 ./notes and assert Deno's permission layer refuses (this validates the least-privilege configuration itself).

Deployment

  • stdio distribution: users run deno run locally; there is no server to host. Commit deno.lock and 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.

Type to search all documentation.