# "Project: Deno MCP server"

This tutorial expands [blueprint 2](/en/docs/projects) into concrete steps. For the `registerTool`, transport, and client-configuration API walkthrough see [Building MCP servers with Deno](/en/docs/ai/mcp-server); this page focuses on project structure, testing, and the evolution path.

## Goal and final shape

```text
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

```bash
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

```ts title="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.

```ts title="tools/read.ts"
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

```ts title="main.ts"
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:

```json
{
  "mcpServers": {
    "project-notes": {
      "command": "deno",
      "args": ["run", "--allow-read=./notes", "main.ts"]
    }
  }
}
```

<Callout type="warn" title="stdout belongs to the protocol">
On a stdio server, stdout is the JSON-RPC channel. Send all logging to `console.error` (stderr), and never echo tool arguments that may contain secrets. See the [MCP server guide](/en/docs/ai/mcp-server).
</Callout>

## 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:

```text
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:

```ts title="protocol_test.ts"
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`:

```json title="deno.json (excerpt)"
{
  "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](/en/docs/ai/mcp-server).
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](/en/docs/deploy/production-baseline) checklist.

## Acceptance checklist

<Checklist id="mcp-project-acceptance" items={[
  "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](https://docs.deno.com/examples/mcp_server/), [MCP TypeScript SDK](https://ts.sdk.modelcontextprotocol.io/server), [Model Context Protocol](https://modelcontextprotocol.io/docs/).
