Module 9 25 min

Build an MCP Server

Create a working MCP server step by step.

You know the architecture; now you'll build the real thing. In this lesson you write a working MCP server in TypeScript — a small "notes" server exposing tools to create and search notes, a resource for reading them, and a prompt template — then connect it to a real host like Claude Desktop. Total: about sixty lines of code.

Why does this exist?

Reading about protocols builds vague familiarity; implementing one builds actual understanding. And practically: the moment you can write an MCP server, every internal system you own — ticketing, analytics, deploy scripts — is one small file away from being usable by any MCP-capable AI app your team runs. This is the highest-leverage sixty lines in the course.

Step 0: project setup

You need Node.js 18+. Create a project and install the official SDK (plus zod, which the SDK uses for input schemas):

mkdir notes-mcp && cd notes-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist

Set "type": "module" in package.json. All code below goes in src/index.ts.

Step 1: a server that exists

Start with the smallest possible server: a name, a version, and a stdio transport.

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

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

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Notes MCP server running on stdio");

Two things deserve attention. First, StdioServerTransport: the host will launch this file as a subprocess and speak JSON-RPC over stdin/stdout — the initialize handshake from last lesson is handled for you by connect(). Second, that log goes to console.error, not console.log. stdout belongs to the protocol — write anything else there and you corrupt the JSON-RPC stream. This is the single most common MCP server bug.

Step 2: state and a first tool

Our "database" is an in-memory array (a real server would use files or a DB — the protocol doesn't care). Tools are registered with a name, metadata, a zod input schema, and a handler:

import { z } from "zod";

type Note = { id: number; title: string; content: string; createdAt: string };
const notes: Note[] = [];
let nextId = 1;

server.registerTool(
  "create_note",
  {
    title: "Create Note",
    description:
      "Create a new note with a title and content. Returns the new note's id.",
    inputSchema: {
      title: z.string().describe("Short title for the note"),
      content: z.string().describe("The note body text"),
    },
  },
  async ({ title, content }) => {
    const note: Note = {
      id: nextId++,
      title,
      content,
      createdAt: new Date().toISOString(),
    };
    notes.push(note);
    return {
      content: [{ type: "text", text: `Created note #${note.id}: "${title}"` }],
    };
  }
);

Trace what each part becomes on the wire: the name, description, and schema are what tools/list returns to clients — and what the LLM reads when deciding whether to call your tool. The handler runs when a tools/call request arrives; its return value becomes the result.content the model sees. Descriptions are your API docs for the model — write them like you're explaining to a new teammate.

Step 3: a search tool (and honest errors)

server.registerTool(
  "search_notes",
  {
    title: "Search Notes",
    description: "Search notes by keyword in title or content.",
    inputSchema: {
      query: z.string().describe("Keyword to search for"),
    },
  },
  async ({ query }) => {
    const q = query.toLowerCase();
    const hits = notes.filter(
      (n) =>
        n.title.toLowerCase().includes(q) ||
        n.content.toLowerCase().includes(q)
    );
    if (hits.length === 0) {
      return {
        content: [{ type: "text", text: `No notes matched "${query}".` }],
      };
    }
    return {
      content: [
        {
          type: "text",
          text: hits
            .map((n) => `#${n.id} ${n.title}: ${n.content.slice(0, 100)}`)
            .join("\n"),
        },
      ],
    };
  }
);

Notice the empty-result branch returns a clear message instead of an empty string or a thrown error. Remember agent error spirals: the model will read this text and decide what to do next, so make failure states as informative as success states. For genuine failures, return { isError: true, content: [...] } so the model knows the call itself failed.

Step 4: a resource and a prompt

Tools are model-controlled; let's add the other two capability types. A resource lets the host attach all notes as context, and a prompt ships a reusable command to the user's UI:

server.registerResource(
  "all-notes",
  "notes://all",
  {
    title: "All Notes",
    description: "Every note as a plain-text document",
    mimeType: "text/plain",
  },
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        text: notes.map((n) => `[#${n.id}] ${n.title}\n${n.content}`).join("\n\n"),
      },
    ],
  })
);

server.registerPrompt(
  "summarize_notes",
  {
    title: "Summarize Notes",
    description: "Ask the model to summarize all current notes",
    argsSchema: { style: z.string().optional() },
  },
  ({ style }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Summarize my notes${style ? ` in a ${style} style` : ""}. Group related notes together.`,
        },
      },
    ],
  })
);

Step 5: run and connect it

Compile and smoke-test with the MCP Inspector — an official debugging UI that plays the client role:

npx tsc
npx @modelcontextprotocol/inspector node dist/index.js

The Inspector opens in your browser: you can see tools/list output, invoke create_note by hand, and inspect every raw JSON-RPC message — the exact hops from the previous lesson's simulation. Once it works, register the server with a real host. For Claude Desktop, add this to claude_desktop_config.json:

{
  "mcpServers": {
    "notes": {
      "command": "node",
      "args": ["/absolute/path/to/notes-mcp/dist/index.js"]
    }
  }
}

Restart the app, and "create a note reminding me to review the RAG lesson" will trigger a real tools/call into your sixty lines.

Debugging checklist

Server not showing up? In order: (1) something printed to stdout — move all logging to console.error; (2) relative path in the config — hosts launch servers from an arbitrary working directory, so use absolute paths; (3) forgot to rebuild after editing — the host runs dist/, not src/.

Build it yourself

  1. Add a delete_note(id) tool. Return an isError result when the id doesn't exist.
  2. Persist notes to a JSON file so they survive restarts.
  3. Add per-note resources (notes://{id}) using ResourceTemplate from the SDK — check the SDK README for the pattern.
  4. Stretch: wrap something you actually use — a TODO file, a small SQLite database — and use it from your AI app for a day. Notice how quickly "my tools" and "my assistant" stop being separate things.

Summary

  • A working MCP server is: McpServer + registered capabilities + a transport, connected. The SDK handles the handshake and JSON-RPC plumbing.
  • registerTool takes name, metadata, a zod input schema, and an async handler; the metadata is the model's only documentation, so write it carefully.
  • stdout is sacred on stdio transport — log to stderr, always.
  • Return informative text for empty results and isError results for failures; models read and act on these.
  • Debug with the MCP Inspector first, then register the compiled server (absolute path!) in your host's config.