All primers
Model Context Protocol Foundational

Build your first MCP server in TypeScript

Ten minutes from zero to an MCP server exposing a useful tool to Claude Code, Cursor, or any MCP client. The SDK, the transports, the gotchas.

November 24, 2025 · 14 min read · Last verified April 17, 2026

The MCP in 20 Minutes primer answers what the Model Context Protocol is and why you’d connect one. This primer is the other half: how you write one. It’s a different skill and worth its own walkthrough.

The short version: writing an MCP server in TypeScript is so cheap that the economics of tool integration have genuinely shifted. Things that used to be “I’ll write a Zapier flow” or “I’ll add it to my agent’s built-in toolbox” are now “I’ll spend 20 minutes and have a server any MCP-compatible client can use forever.” The rest of this piece is the exact path from pnpm init to a working, shippable server.

What we’re going to build

One server, one tool, one real problem. We’re going to build notes-search — an MCP server that exposes a single tool, search_notes, which does full-text search across a local folder of Markdown files.

I picked this example for three reasons. First, every developer has a notes folder somewhere — Obsidian, plain Markdown, Bear, Roam export, whatever. Second, it’s the exact shape of 80% of useful MCP servers: “let the model reach into my private data without me copy-pasting.” Third, there’s no vendor API to stub — you can actually run this end to end on your laptop in ten minutes.

The finished server is about 80 lines of TypeScript. By the end of this primer you’ll have it connected to Claude Code and will be able to ask, “What were my notes from that conversation with the CFO last month?” and actually get useful results back.

Prereqs

You need three things:

  • Node 20 or later. The SDK uses modern ES module features.
  • TypeScript. Either globally (pnpm add -g typescript) or project-local.
  • An MCP client to test against. Claude Code is what I’ll use here; Cursor or the MCP Inspector work identically.

I’ll use pnpm throughout but npm and yarn work equivalently. Swap the command names and everything else is the same.

The project skeleton

mkdir notes-search-mcp && cd notes-search-mcp
pnpm init
pnpm add @modelcontextprotocol/sdk zod
pnpm add -D typescript @types/node
npx tsc --init

That gives you a directory with package.json, tsconfig.json, and node_modules. Two small edits to set up properly.

In tsconfig.json, switch the output target to modern Node and enable ES modules:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": false
  },
  "include": ["src/**/*"]
}

In package.json, add "type": "module", a build script, and a bin entry so the server can be installed as a CLI:

{
  "name": "notes-search-mcp",
  "version": "0.1.0",
  "type": "module",
  "bin": {
    "notes-search-mcp": "./dist/server.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/server.js"
  }
}

The bin entry matters for publishing — it’s what lets someone install your package globally and have a notes-search-mcp command appear on their PATH. We’ll come back to that at the end.

Make the src/ directory and you’re ready to write code.

The server, block by block

Create src/server.ts. The full file is at the bottom of this section; we’ll walk it in pieces first.

Imports and instantiation

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile, readdir } from "node:fs/promises";
import { join, extname } from "node:path";

const NOTES_DIR = process.env.NOTES_DIR;
if (!NOTES_DIR) {
  process.stderr.write("error: NOTES_DIR env var must be set\n");
  process.exit(1);
}

const server = new McpServer({
  name: "notes-search",
  version: "0.1.0",
});

The shebang line at the top lets the compiled file run directly as an executable once you chmod +x it, which is what the bin entry relies on. The imports pull the server class, the stdio transport, zod for schema validation, and Node’s standard filesystem bits.

The NOTES_DIR read is doing something important: it gets the path to the notes folder from an environment variable rather than hardcoding one. This is how MCP servers receive configuration from their clients — the client’s config file sets environment variables that the server process inherits. Don’t bake paths or tokens into the source; accept them from the environment and fail loudly if they’re missing.

Note the failure path uses process.stderr.write and process.exit(1), not console.log or throw. We’ll come back to that — it’s one of the two most common bugs in first MCP servers.

Registering the tool

server.tool(
  "search_notes",
  "Search the user's personal notes folder for a case-insensitive substring. " +
    "Returns up to 20 matches with filename, line number, and a snippet of " +
    "context. Use this when the user asks about something from their notes, " +
    "past meetings, or anything they mention having 'written down'.",
  {
    query: z
      .string()
      .min(2)
      .describe("Case-insensitive substring to search for across all notes."),
    max_results: z
      .number()
      .int()
      .min(1)
      .max(50)
      .optional()
      .describe("Maximum matches to return. Defaults to 20."),
  },
  async ({ query, max_results = 20 }) => {
    const results = await searchNotes(query, max_results);
    if (results.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: `No matches for "${query}" in ${NOTES_DIR}.`,
          },
        ],
      };
    }
    const body = results
      .map(
        (r) =>
          `**${r.file}** (line ${r.line})\n${r.snippet}`,
      )
      .join("\n\n---\n\n");
    return {
      content: [
        { type: "text", text: `Found ${results.length} matches:\n\n${body}` },
      ],
    };
  },
);

This is the whole interesting part of the server, and every decision here matters.

The tool description is the most important prose you’ll write. When the model decides whether to call your tool, it reads that description. Not the tool name — the description. A good description answers: what does this do, what should I pass, and when should I reach for it instead of something else? I spend more time on tool descriptions than on tool bodies, because a tool the model never calls is worthless no matter how correct the implementation.

Compare two descriptions for the same tool:

  • Bad: “Searches notes.”
  • Good: “Search the user’s personal notes folder for a case-insensitive substring. Returns up to 20 matches with filename, line number, and a snippet of context. Use this when the user asks about something from their notes, past meetings, or anything they mention having ‘written down’.”

The good one gives the model a use-case trigger (“when the user asks about X”), a shape expectation (“returns filename + line + snippet”), and a hint about semantics (“case-insensitive substring” — so don’t pre-fuzz the query). The model will use the second tool correctly three times as often.

The zod schema is the args contract. The SDK uses it to validate inputs before your tool body runs, and it’s also what gets serialized into the tool-call schema the model sees. The .describe() calls on each field show up in that schema. Again — descriptions matter more than names.

The response is structured Markdown. MCP tool responses are flexible, but the model handles prose and lightly-formatted Markdown best. Return JSON-as-string only when the consumer of your output is another tool, not the model. For a search result like this, Markdown with filenames in bold and separators between hits reads perfectly in the client.

Failure returns a text message, not an exception. If zero matches come back, we return an informative string rather than letting the tool look like it failed. The model sees “no matches in NOTES_DIR” and will either try a different query or tell the user nothing was found. Exceptions thrown from a tool body propagate as transport errors, which gives the model much less to work with.

The search implementation

type Match = { file: string; line: number; snippet: string };

async function searchNotes(
  query: string,
  maxResults: number,
): Promise<Match[]> {
  const needle = query.toLowerCase();
  const files = await listMarkdownFiles(NOTES_DIR);
  const matches: Match[] = [];

  for (const file of files) {
    const content = await readFile(file, "utf8");
    const lines = content.split("\n");
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].toLowerCase().includes(needle)) {
        matches.push({
          file: file.replace(NOTES_DIR + "/", ""),
          line: i + 1,
          snippet: contextWindow(lines, i),
        });
        if (matches.length >= maxResults) return matches;
      }
    }
  }
  return matches;
}

async function listMarkdownFiles(
  dir: string,
  acc: string[] = [],
): Promise<string[]> {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const e of entries) {
    const full = join(dir, e.name);
    if (e.isDirectory()) await listMarkdownFiles(full, acc);
    else if (extname(e.name) === ".md") acc.push(full);
  }
  return acc;
}

function contextWindow(lines: string[], i: number): string {
  const start = Math.max(0, i - 1);
  const end = Math.min(lines.length, i + 2);
  return lines.slice(start, end).join("\n");
}

Nothing clever. Recursively walk the directory, read each .md file, scan line-by-line, bail early when we hit the cap. For a few hundred files of notes this is instant; if your notes folder is five thousand files you’d want a proper index, but at that point the tool’s description should also say “this is full-text search, not semantic” and let the model decide.

The point isn’t that this implementation is optimal — it’s that it’s honest. The tool does exactly what the description says. No fake embeddings, no silent LLM re-ranking, no “we’ll fix it later.” A working slice you trust beats a clever slice you don’t.

Connecting the transport

await server.connect(new StdioServerTransport());

One line. The server is now waiting for MCP messages on stdin and writing responses to stdout.

The full file

#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readFile, readdir } from "node:fs/promises";
import { join, extname } from "node:path";

const NOTES_DIR = process.env.NOTES_DIR;
if (!NOTES_DIR) {
  process.stderr.write("error: NOTES_DIR env var must be set\n");
  process.exit(1);
}

const server = new McpServer({
  name: "notes-search",
  version: "0.1.0",
});

type Match = { file: string; line: number; snippet: string };

async function listMarkdownFiles(
  dir: string,
  acc: string[] = [],
): Promise<string[]> {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const e of entries) {
    const full = join(dir, e.name);
    if (e.isDirectory()) await listMarkdownFiles(full, acc);
    else if (extname(e.name) === ".md") acc.push(full);
  }
  return acc;
}

function contextWindow(lines: string[], i: number): string {
  const start = Math.max(0, i - 1);
  const end = Math.min(lines.length, i + 2);
  return lines.slice(start, end).join("\n");
}

async function searchNotes(
  query: string,
  maxResults: number,
): Promise<Match[]> {
  const needle = query.toLowerCase();
  const files = await listMarkdownFiles(NOTES_DIR);
  const matches: Match[] = [];
  for (const file of files) {
    const content = await readFile(file, "utf8");
    const lines = content.split("\n");
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].toLowerCase().includes(needle)) {
        matches.push({
          file: file.replace(NOTES_DIR + "/", ""),
          line: i + 1,
          snippet: contextWindow(lines, i),
        });
        if (matches.length >= maxResults) return matches;
      }
    }
  }
  return matches;
}

server.tool(
  "search_notes",
  "Search the user's personal notes folder for a case-insensitive substring. " +
    "Returns up to 20 matches with filename, line number, and a snippet of " +
    "context. Use this when the user asks about something from their notes, " +
    "past meetings, or anything they mention having 'written down'.",
  {
    query: z
      .string()
      .min(2)
      .describe("Case-insensitive substring to search for across all notes."),
    max_results: z
      .number()
      .int()
      .min(1)
      .max(50)
      .optional()
      .describe("Maximum matches to return. Defaults to 20."),
  },
  async ({ query, max_results = 20 }) => {
    const results = await searchNotes(query, max_results);
    if (results.length === 0) {
      return {
        content: [
          { type: "text", text: `No matches for "${query}" in ${NOTES_DIR}.` },
        ],
      };
    }
    const body = results
      .map((r) => `**${r.file}** (line ${r.line})\n${r.snippet}`)
      .join("\n\n---\n\n");
    return {
      content: [
        { type: "text", text: `Found ${results.length} matches:\n\n${body}` },
      ],
    };
  },
);

await server.connect(new StdioServerTransport());

Roughly 80 lines, shippable as-is. Build it:

pnpm build
chmod +x dist/server.js

Wire it to Claude Code

Open ~/.claude/settings.json (or your project’s .claude/settings.json if you want it project-scoped). Add an entry under mcpServers:

{
  "mcpServers": {
    "notes-search": {
      "command": "node",
      "args": ["/absolute/path/to/notes-search-mcp/dist/server.js"],
      "env": {
        "NOTES_DIR": "/Users/you/Documents/Notes"
      }
    }
  }
}

Use absolute paths. Relative paths resolve against Claude Code’s working directory, which is not where you think it is. Restart Claude Code so it picks up the new config.

Now ask: “Search my notes for anything about the onboarding redesign.” Claude will call search_notes, the tool body will crawl your Markdown files, and you’ll get formatted results back. If you don’t — skip to the pitfalls section, you likely hit one of them.

Stdio vs streamable HTTP

Stdio is the right default. The client launches your server as a subprocess and they talk through stdin/stdout. Everything is local, there’s no network surface, and the process lifetime is tied to the client session.

Streamable HTTP is what you reach for when the server should not be local — when multiple users hit the same server, when the server needs a deployment environment it can only get in the cloud, or when you want to offer your server as a hosted thing.

The code change is one line:

// instead of:
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
await server.connect(new StdioServerTransport());

// for HTTP:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const transport = new StreamableHTTPServerTransport({ port: 3000 });
await server.connect(transport);

The rest of the server — the server.tool registrations, the handler bodies, everything — is identical. The SDK keeps transports pluggable on purpose.

A quick comparison of when each makes sense:

ConcernStdioStreamable HTTP
Who runs the serverThe client, as a subprocessYou, on a host
Where state livesClient machineWherever you deploy
AuthEnvironment variables the client injectsHeaders, OAuth, API keys
Multi-userOne user per processMany users per server
LatencyNear-zeroNetwork round-trip
Right forPersonal tools, local data, CLI integrationsSaaS MCP servers, team-shared tools

The unofficial rule: if the data the tool touches lives on your laptop, use stdio. If it lives in a database somewhere else, use HTTP. Plenty of servers offer both and let the user pick.

Cloudflare Workers and Vercel both have MCP-specific hosting paths as of late 2025. Both accept the SDK’s HTTP transport output without modification.

What to expose, what not to

A few design rules that I’ve paid for the hard way:

  • One tool per discrete action. get_issue, list_issues, create_issue — three tools, not one manage_issues with a mode parameter. The model is much better at picking among well-named tools than at figuring out the right mode for a mega-tool.
  • Descriptions are the product. A 2- to 4-sentence description beats a clever name every time. Say what the tool does, what it returns, and when to use it. I’ve had tools go from “never called” to “called appropriately” by rewriting just the description.
  • Return Markdown, not blobs. The model reads your output as text. Format accordingly — headers, bullets, separators. Avoid returning screenshots or binary payloads unless the tool’s whole purpose is media.
  • Fail with direction. “Tool error: 404” tells the model nothing. “No user found with that email. Try listing users first to get a valid ID” gives the model a next move. Error messages are a UX surface for the model.
  • Default cautiously on side effects. If a tool mutates state (sends email, deletes rows, creates issues), make that explicit in the description and consider a dry-run flag for the first version. The model will call tools aggressively once it trusts them.

The anti-pattern is the do_everything tool: a single entry point with a giant union type for what it does. It’ll work, and it’ll be subtly worse than any other shape. Resist.

Testing with the MCP Inspector

The MCP Inspector is the dev loop I reach for before touching Claude Code. It’s a local web UI that speaks the protocol directly to your server, shows you the tool schemas, lets you invoke tools with specific arguments, and prints raw responses.

Run it against your stdio server:

npx @modelcontextprotocol/inspector node dist/server.js

It’ll print a localhost URL. Open it. You’ll see:

  • The search_notes tool listed with its description and schema
  • A form where you can enter a query and max_results and click “Call tool”
  • The raw response your handler returned

This is worth ten minutes of poking before you wire the server into Claude Code, because the Inspector tells you exactly what your server is emitting. If the tool shows up with the wrong description, the Inspector shows it immediately. If the response is shaped wrong, the Inspector rejects it with a clear error. By the time Claude Code tries to use it, all the dumb bugs are gone.

I also keep the Inspector around for regression testing — it’s the closest thing MCP has to a REPL.

Publishing as an npm package

Once the server works, publishing it is close to free. Three prerequisites:

  1. The bin entry in package.json points at the compiled dist/server.js.
  2. The top line of src/server.ts is #!/usr/bin/env node and the compiled output keeps it.
  3. package.json has "type": "module" and lists dist/ in a files array (or the defaults are fine for a small package).

Then:

pnpm build
npm publish --access public

Now any user with Node can install it and run it from anywhere:

pnpm add -g notes-search-mcp

Or skip the install entirely via npx. The config users paste into their client becomes:

{
  "mcpServers": {
    "notes-search": {
      "command": "npx",
      "args": ["-y", "notes-search-mcp"],
      "env": {
        "NOTES_DIR": "/Users/them/Documents/Notes"
      }
    }
  }
}

That -y flag on npx auto-confirms the install prompt. Pinning a specific version is a good idea for anything users rely on — "args": ["-y", "notes-search-mcp@0.1.3"] — because otherwise an npx pull can drift under people.

This is the whole reason the ecosystem has thousands of servers: the publish step is ten seconds and the install step, for users, is pasting JSON.

Auth

If your tool hits a user-specific API — GitHub, Linear, your own SaaS — accept the credential from the environment and document the expected variable name. Never hardcode. Never prompt interactively (stdio transport has no stdin for you to prompt on).

const token = process.env.GITHUB_TOKEN;
if (!token) {
  process.stderr.write("error: GITHUB_TOKEN env var required\n");
  process.exit(1);
}

The client config supplies the value:

"env": {
  "GITHUB_TOKEN": "ghp_abc123..."
}

Two further rules for production:

  1. Use environment indirection when the client supports it. Claude Code understands "GITHUB_TOKEN": "${env:GITHUB_TOKEN}" — it reads the value from the user’s shell rather than storing it in the settings file. Fewer plaintext secrets on disk.
  2. For hosted (HTTP) servers, do not ship without auth. An unauthenticated HTTP MCP server is an open remote-code endpoint for whatever your tools do. Use bearer tokens, OAuth, or IP allowlists. Plan this in from the first deploy, not after.

Pitfalls

The same small set of bugs trip up almost every first MCP server. Knowing them up front saves an hour of debugging.

Do not write anything to stdout. This is the big one. The stdio transport uses stdout as its message channel. A stray console.log in your tool body will emit non-protocol bytes into that stream and the client will disconnect with a cryptic parse error. Every log, error, trace, or debug message must go to stderr:

// wrong — breaks the transport
console.log("searching...");

// right
process.stderr.write("searching...\n");
// or
console.error("searching...");

console.error writes to stderr, which is safe. If you have logging utilities that default to stdout, either reconfigure them or only use them in the HTTP transport path.

Async init inside the transport is a footgun. If your server needs to load data, connect to a database, or warm up a cache, do it before calling server.connect(). Kicking off an async init inside a handler that expects the server to be ready causes the first few tool calls to race against initialization:

// wrong
server.tool("query", ..., async (args) => {
  if (!db) db = await connectDb();  // first call races
  ...
});

// right
const db = await connectDb();       // finished before serving
server.tool("query", ..., async (args) => { ... });
await server.connect(new StdioServerTransport());

Tool schemas that drift from the body. If the zod schema says max_results is required but the body defaults it, you get silent disagreement. If the schema says the query is a string and the body parses it as a number, you get runtime errors the model sees as tool failures. Treat the schema and the handler as one unit; change them together.

HTTP without auth. Covered above but worth repeating: an open HTTP MCP endpoint that does real work is a vulnerability. Authenticate every request before it reaches your tool handlers.

Using require in an ESM package. The SDK is ESM. Your package.json needs "type": "module". Your imports need the .js suffix even when they point at .ts source. If you see “Cannot use require() of an ES module,” you skipped one of those.

Hot-reload breaking stdio. When developing, resist the urge to nodemon the server. Stdio clients hold the process open and won’t re-handshake when the file changes; you’ll spend twenty minutes wondering why your new tool isn’t appearing. Build, restart Claude Code, test, iterate.

Five-minute starting path

If you want to ship an MCP server today and read the above as reference:

  1. mkdir your-server && cd your-server && pnpm init
  2. pnpm add @modelcontextprotocol/sdk zod and pnpm add -D typescript @types/node
  3. Copy the full src/server.ts from above
  4. Replace the search_notes tool body with your one real operation
  5. Rewrite the tool description to tell the model when and why to call it
  6. Build, add to your Claude Code config with an absolute path, restart, test

You’ll be shipping within an hour the first time. Thirty minutes the second.

Where next

  • The MCP in 20 Minutes primer for the protocol-level picture — clients, servers, hosts, the three capability types, and why the broader ecosystem matters.
  • Look at the official reference servers when you want idiomatic patterns for filesystem, database, and API-wrapper shapes. The GitHub and Postgres servers are especially worth reading.
  • Keep one question in your pocket: which papercut in my daily workflow is a 50-line MCP server away from ending? That’s the one to build next. The ceremony is low enough now that “I should just write one” is the right answer more often than it used to be.

The governance picture is worth a line, too: MCP is Anthropic-governed today, with a neutral foundation rumored for 2026. Nothing you build on the current SDK becomes obsolete if that happens — the protocol surface is stable and the SDKs would transfer along with the spec. Build against what’s there.