MCP Servers 101 and Five Design Rules
A minimal, tested MCP server, plus how to defend your tools against agent inputs that look nothing like human typos.
MCP is an open protocol that connects AI clients to tools and data. Write one server and any MCP client can call it. Here is a minimal server that reads local notes, followed by five rules we learned running our own agent tools.
1. A minimal working server
npm i @modelcontextprotocol/sdk zod
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import path from 'node:path';
import fs from 'node:fs/promises';
const ROOT = path.resolve(process.env.NOTES_DIR ?? './notes');
async function readNoteImpl({ name }) {
if (!/^[\w.-]{1,64}$/.test(name) || name.includes('..')) throw new Error(`invalid name: ${JSON.stringify(name)}`);
const p = path.resolve(ROOT, name + '.md');
if (!p.startsWith(ROOT + path.sep)) throw new Error('path escapes root');
return fs.readFile(p, 'utf8');
}
const server = new McpServer({ name: 'notes', version: '0.1.0' });
server.registerTool('read_note', {
description: 'Read one markdown note by name (without .md). Use when the user refers to a saved note.',
inputSchema: { name: z.string().describe('note name, e.g. "deploy-checklist"') },
}, async (args) => ({ content: [{ type: 'text', text: await readNoteImpl(args) }] }));
await server.connect(new StdioServerTransport());
In our test, read_note({name:"hello"}) returned the note and read_note({name:"../../etc/passwd"}) came back with isError: true.
2. Five rules
- Keep one implementation behind both your CLI and your MCP server. Generate schemas from your types and write tests once, against the implementation. Design for the agent first; the human CLI is a by-product.
- Accept JSON rather than piles of flags. Models write JSON well, and meaning gets lost when it is spread across many flags.
- Expect agent-shaped mistakes: path traversal (
../../.ssh), queries embedded in IDs, double URL encoding, invisible control characters. Validate against an allowlist, and check that the resolved path is still inside the root. - Let callers shrink responses with field masks or NDJSON. Every byte you return costs context.
- Say when to use the tool in its description. Put invariants in your skill docs.
To limit what a tool can write, use a read-only wrapper: permission design.
3. Always-loaded MCP costs context on every turn
Demoting 15 skills nobody had called in 30 days cut always-loaded descriptions from 14.5k to 7.3k characters. With a local model, the same task took 24.6 s at 2,834 tokens per turn and 4 min 35 s at 30,301 tokens, mostly tool definitions.
Measurements from trimming always-loaded text: Trim Your CLAUDE.md.
4. When a deleted server comes back
“Resurrected” servers are usually leftover config: user-scope registration, per-project local settings, an enable-all-project-servers flag, or stale allow-list entries. And GUI-launched clients may not inherit your shell env, so ${API_KEY} can arrive empty. Log whether the key is present (never its value) at startup.
When choosing and installing an existing MCP server, see How to Choose MCP Servers for criteria and how to narrow its permissions.