teachyou.ai academy
← All posts
MCPTooling

How to Build Your First MCP Server in TypeScript (Step-by-Step)

Pramod Dutta · Jun 29, 2026 · 15 min read

If you have spent any time wiring an LLM up to your own data or internal APIs, you already know the pain: every assistant wants a slightly different plugin format, every framework reinvents tool-calling, and none of it is portable. The Model Context Protocol (MCP) exists to fix exactly this. It gives you one server implementation that any MCP-aware client — Claude Desktop, Claude Code, or a custom agent you build yourself — can talk to, without you rewriting the integration for each one. In this walkthrough we will build a real MCP server in TypeScript from an empty folder to a working tool call inside Claude Desktop, and along the way flag the mistakes that eat most people's first afternoon with MCP.

What MCP actually is (in practical terms)

Strip away the spec language and MCP is a client-server protocol over JSON-RPC. Your MCP server exposes capabilities — tools, resources, and prompts — and a client (an AI application) connects to it, lists what is available, and calls into it on the model's behalf.

The three primitives you will meet immediately:

  • Tools — functions the model can invoke, each with a name, a description, and an input schema. This is what we are building today.
  • Resources — read-only data the client can fetch and hand to the model as context, like a file or a database row.
  • Prompts — reusable prompt templates the server can offer to the client.

For a first server, tools are the highest-leverage primitive. A tool is essentially a typed RPC endpoint: the client sends structured arguments, your server runs code, and you return structured content back. The protocol handles discovery (the client asks "what tools do you have?") and invocation (the client says "call this tool with these arguments") — you just implement the logic in between.

Transport matters less than people expect at this stage. Your server can talk over stdio (the client spawns your process and pipes JSON-RPC over stdin/stdout) or over HTTP (typically with Server-Sent Events for streaming). For a local tool used by Claude Desktop or Claude Code, stdio is the default and the simplest to reason about — no ports, no auth handshake, the client owns the process lifecycle. We will build with stdio and mention HTTP where it changes the picture.

Project setup

Start with a clean Node project. MCP servers in TypeScript lean on the official @modelcontextprotocol/sdk package, plus zod for schema validation, which the SDK uses natively for input validation.

mkdir docs-search-mcp
cd docs-search-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

Adjust tsconfig.json so the output is friendly to Node's module resolution — this is the single most common setup mistake, so get it right up front:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Add a couple of scripts to package.json so you can run the server directly during development without a separate compile step:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node build/index.js"
  }
}

Note "type": "module" — the SDK ships ESM, and mixing that with CommonJS output is the second most common setup mistake. Keep your imports as ESM (import { X } from "y") throughout.

Create the folder structure:

mkdir src
touch src/index.ts

That is the entire scaffold. No framework, no boilerplate generator required — MCP servers are intentionally thin.

Building the example: a "search internal docs" tool

Rather than another toy weather example, let's build something closer to what you will actually ship: a tool that searches an internal knowledge base and returns matching snippets. The shape generalizes to a get-weather tool, a database lookup, a ticket search — anything where the model needs to call out to real data.

Here is the server skeleton in src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "docs-search-mcp",
  version: "1.0.0",
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("docs-search-mcp running on stdio");
}

main().catch((err) => {
  console.error("Fatal error starting server:", err);
  process.exit(1);
});

Two details worth calling out immediately. First, console.error, not console.log — on stdio transport, stdout is the JSON-RPC channel. Anything you print with console.log corrupts the protocol stream and the client will fail with cryptic parse errors. Logging always goes to stderr. Second, the main().catch(...) wrapper matters: an unhandled rejection during startup should exit loudly, not hang silently while Claude Desktop waits forever for a handshake that never comes.

Defining the tool: name, description, and input schema

This is the part worth slowing down on, because the description and schema are the actual interface the model reasons against — not your internal code. The model never sees your TypeScript; it sees the tool's name, description, and JSON schema, and decides when and how to call it based on that alone. Treat this like writing a public API contract, not an internal function signature.

const SearchDocsInputSchema = z.object({
  query: z
    .string()
    .min(1, "query cannot be empty")
    .describe("The search phrase to look up in the internal docs, e.g. 'reset user password'"),
  maxResults: z
    .number()
    .int()
    .min(1)
    .max(20)
    .default(5)
    .describe("Maximum number of matching documents to return"),
  space: z
    .enum(["engineering", "support", "product"])
    .optional()
    .describe("Restrict the search to a specific documentation space, if known"),
});

server.registerTool(
  "search_internal_docs",
  {
    title: "Search Internal Docs",
    description:
      "Searches the company's internal documentation for pages relevant to a query. " +
      "Use this when the user asks about internal processes, runbooks, or product " +
      "documentation that would not be public knowledge. Returns titles, URLs, and " +
      "short snippets, not full page contents.",
    inputSchema: SearchDocsInputSchema.shape,
  },
  async ({ query, maxResults, space }) => {
    // handler goes here — see next section
    return { content: [{ type: "text", text: "placeholder" }] };
  }
);

A few things that make or break how reliably the model calls this correctly:

  • The description states when to use the tool, not just what it does. "Searches internal docs" is weaker than "use this when the user asks about internal processes... that would not be public knowledge" — the latter gives the model a decision rule.
  • Every field has its own `.describe()`. The SDK converts your Zod schema to JSON Schema automatically, and those per-field descriptions become the property descriptions the model reads. Skipping them is the single biggest cause of malformed calls — the model guesses at intent instead of being told.
  • Constrain what you can. z.enum([...]) for the space field is doing real work: it tells the model the exact valid values instead of hoping it infers them from prose, and it gives you a validation error for free if it guesses wrong.
  • Sensible defaults reduce required fields. maxResults defaults to 5, so the model does not have to specify it on every call — fewer required arguments means fewer chances to fail validation.

Implementing the handler

The handler is a plain async function that receives already-validated, typed arguments (thanks to Zod) and must return a specific response shape: a content array of typed blocks. Here is a realistic implementation against a hypothetical internal search API:

server.registerTool(
  "search_internal_docs",
  {
    title: "Search Internal Docs",
    description:
      "Searches the company's internal documentation for pages relevant to a query. " +
      "Use this when the user asks about internal processes, runbooks, or product " +
      "documentation that would not be public knowledge.",
    inputSchema: SearchDocsInputSchema.shape,
  },
  async ({ query, maxResults, space }) => {
    try {
      const results = await searchDocsApi({ query, space, limit: maxResults });

      if (results.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No documents found for "${query}"${space ? ` in the ${space} space` : ""}.`,
            },
          ],
        };
      }

      const formatted = results
        .map(
          (r, i) =>
            `${i + 1}. **${r.title}**\n   ${r.url}\n   ${r.snippet}`
        )
        .join("\n\n");

      return {
        content: [{ type: "text", text: formatted }],
      };
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return {
        content: [{ type: "text", text: `Docs search failed: ${message}` }],
        isError: true,
      };
    }
  }
);

async function searchDocsApi(params: {
  query: string;
  space?: string;
  limit: number;
}): Promise<Array<{ title: string; url: string; snippet: string }>> {
  const res = await fetch("https://internal-docs.example.com/api/search", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.DOCS_API_TOKEN}`,
    },
    body: JSON.stringify(params),
  });

  if (!res.ok) {
    throw new Error(`docs API returned ${res.status}: ${await res.text()}`);
  }

  const data = (await res.json()) as { results: Array<{ title: string; url: string; snippet: string }> };
  return data.results;
}

The pattern to internalize here: wrap the handler body in try/catch and return `isError: true` on failure, rather than throwing. A thrown exception from inside the handler becomes a transport-level error, which most clients surface as a blunt "tool call failed" with no detail the model can act on. Returning a structured error message as content lets the model read the failure and decide what to do next — retry with different arguments, tell the user, or fall back to another tool.

Running and testing the server locally

Before wiring this into any client, verify the server works in isolation. The most useful tool here is the official MCP Inspector, a small web UI that speaks the protocol on your behalf so you are not hand-crafting JSON-RPC messages.

npx @modelcontextprotocol/inspector npx tsx src/index.ts

This spawns your server over stdio and opens a browser UI where you can see the registered tools, inspect the generated JSON schema for each one, and fire test calls with arbitrary arguments. This step matters more than it looks — it is where you catch schema problems before a client ever touches your server. Check three things here specifically:

  1. Does `search_internal_docs` show up in the tool list with the description you wrote? If the description is truncated or missing, check for a typo in the registration call.
  2. Does the generated schema show `query` as required and `maxResults`/`space` as optional? This confirms Zod's .optional() and .default() are translating correctly.
  3. Does a bad call fail the way you expect? Try calling it with query: "" and confirm you get a validation error back, not a silent pass-through.

If you don't want the extra dependency, you can also just run the server directly and pipe raw JSON-RPC at it for a smoke test — but the Inspector's UI pays for itself the first time you are debugging a schema mismatch.

Connecting it to a client (Claude Desktop / Claude Code)

Once the server behaves in the Inspector, wire it into a real client. Both Claude Desktop and Claude Code use the same shape of config: a name, a command, and arguments to launch your server as a subprocess.

For Claude Desktop, edit the config file (~/Library/Application Support/Claude/claude_desktop_config.json on macOS, or the equivalent %APPDATA%\Claude\claude_desktop_config.json on Windows):

{
  "mcpServers": {
    "docs-search": {
      "command": "node",
      "args": ["/absolute/path/to/docs-search-mcp/build/index.js"],
      "env": {
        "DOCS_API_TOKEN": "your-token-here"
      }
    }
  }
}

A few notes that save debugging time:

  • Use absolute paths. The client spawns your process from its own working directory, not your project folder — a relative path here will fail silently or point at the wrong file.
  • Build before you point at `build/index.js`. Run npm run build first, or point command/args at npx tsx src/index.ts if you want to iterate without rebuilding each time.
  • Secrets go in `env`, not in your source. The config file lives outside your repo, which is exactly where API tokens for a personal MCP server belong — see the auth section below for the caveats.

Restart the client fully after editing the config — Claude Desktop only reads this file on launch. If the tool does not appear afterward, that is your first signal something in the process spawn is failing, not the tool logic itself.

For Claude Code, the equivalent is a project- or user-level MCP config (or the claude mcp add command), pointing at the same command and args. The protocol side is identical — Claude Code and Claude Desktop are both just MCP clients talking to the same server binary.

Once connected, confirm the model can see the tool by asking something that should trigger it directly, like "search our internal docs for how to rotate an API key." Watch whether the model actually invokes search_internal_docs or answers from general knowledge — if it does the latter, revisit your description; that is almost always a discoverability problem, not a code bug.

Common pitfalls (and how to actually fix them)

Schema validation errors that seem to come from nowhere. Almost always traced to one of two things: a field description missing (so the model passes a plausible-but-wrong value), or a mismatch between what Zod validates and what you actually destructure in the handler. If you add a field to the schema, add it to the handler's destructured parameters in the same commit — the two drift silently otherwise, and TypeScript will not catch it if you're loose with types.

stdout pollution. Any stray console.log, a dependency that logs to stdout by default, or an uncaught process.stdout.write will corrupt the JSON-RPC stream on a stdio server. Symptoms look like intermittent "unexpected token" errors on the client side that seem unrelated to your tool code. Audit third-party libraries you pull in — logging libraries are the usual culprit — and redirect everything to stderr.

Missing error handling around external calls. A network timeout, a 500 from your internal API, or a malformed response should never throw an uncaught exception out of the handler. Wrap every external call, catch specific failure modes where you can (timeout vs. 4xx vs. 5xx), and return them as isError: true content so the model has something to reason about instead of a dead connection.

Auth done wrong. For a local stdio server, putting a token in the client config's env block is reasonable — it never leaves the user's machine and is not transmitted over a network. This changes completely the moment you move to an HTTP-based MCP server serving multiple users: at that point you need proper request-level authentication (OAuth or signed tokens per the MCP authorization spec), not a shared static token baked into a config file. Do not carry the "just put it in env" habit from your local prototype into a hosted server — that is how internal API tokens end up leaked in a shared deployment.

Overly broad tool scope. A tool named manage_docs that both searches and deletes documents is a design smell — it is harder for the model to reason about safely, and harder for you to gate permissions on. Prefer several narrow, well-described tools (search_internal_docs, create_doc_page) over one tool with a mode parameter that switches behavior. Narrow tools also make it easier to selectively enable or disable capabilities per client.

Forgetting `isError` versus throwing. Worth repeating because it is the fix most people skip: a thrown error kills the call at the transport layer with no recovery path for the model. Returning isError: true with a text explanation lets the model retry, adjust arguments, or explain the failure to the user — a materially better experience for almost no extra code.

Adding a second tool (and why it's easy once the first one works)

The value of getting the pattern right on tool one is that tool two is almost mechanical. Say you want to add create_doc_page alongside search:

const CreateDocInputSchema = z.object({
  title: z.string().min(1).describe("Title of the new documentation page"),
  body: z.string().min(1).describe("Markdown content of the page"),
  space: z
    .enum(["engineering", "support", "product"])
    .describe("Which documentation space this page belongs to"),
});

server.registerTool(
  "create_doc_page",
  {
    title: "Create Internal Doc Page",
    description:
      "Creates a new page in the internal documentation system. Use this only when " +
      "the user explicitly asks to save, document, or write something down — never " +
      "proactively.",
    inputSchema: CreateDocInputSchema.shape,
  },
  async ({ title, body, space }) => {
    try {
      const page = await createDocPage({ title, body, space });
      return {
        content: [{ type: "text", text: `Created page "${title}" at ${page.url}` }],
      };
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return {
        content: [{ type: "text", text: `Failed to create page: ${message}` }],
        isError: true,
      };
    }
  }
);

Notice the description explicitly says "never proactively" — for any tool with a side effect (writes, deletes, sends), be explicit in the description about when it should and should not fire. This is your main lever for controlling agentic behavior; you cannot patch bad judgment in afterward with a config flag.

Where to go from here

A single-tool stdio server covers most personal and internal use cases, but production deployments raise a few questions this walkthrough deliberately left aside: how you version tool schemas without breaking existing clients, how you handle concurrent requests if your handler talks to a stateful backend, when to move from stdio to a hosted HTTP transport, and how to design resources and prompts alongside tools for richer context. Those decisions are where most of the real engineering work in an MCP integration lives, and they're the exact gap between "I made a demo" and "I shipped something a team relies on."

That is the territory we go deep on in Building & Integrating MCP Servers, the course module Ira Menon and I teach at teachyou.ai — from schema design discipline through auth models to deploying MCP servers that other engineers on your team actually plug into their own agents.