AI Engineer Roadmap All lessons Interactive Quiz MernStackDev GitHub

Build a Custom MCP Server

Play the interactive lesson: Build a custom MCP serverRead on GitHub
On this page
  1. Explain it like I'm new
  2. Watch it
  3. How it works
  4. Code example
  5. When to use it / when not to
  6. Common mistakes
  7. Recap
  8. Interview questions
  9. Go deeper

Level: Advanced · Reading time: 14 min · Phase: 6 — MCP

In one line: Build a small notes server step by step: design narrow tools, validate input, expose notes as resources, add a prompt, test it and connect it to a host safely.

Before you start: MCP server, MCP tools and MCP resources. You should be able to read the message shapes for each.

Explain it like I'm new

Building a server is like writing a tiny API for an AI instead of a human. A human reads docs. The model reads your tool descriptions and schemas. So the craft is: few clear actions, strict inputs, small outputs.

You will build a notes server. A fuller, finished version lives in mcp-notes-server (persistence, search, tests and a Claude Desktop config). Read it after this walkthrough.

Words to know:

Watch it

Build a custom MCP server: A notes server, from idea to a connected host.
Build a custom MCP server: A notes server, from idea to a connected host.

It plays on a loop. Read the caption in the box, one step at a time. Want to go at your own pace? Step through it with Previous and Next.

How it works

  1. Pick the job. "Let the model save and find my notes."
  2. Design narrow tools. create_note(title, body), list_notes(), search_notes(query). No raw SQL, no shell.
  3. Define schemas. Required fields, max lengths, types. Reject the rest.
  4. Choose storage. Start in memory, then a JSON file or SQLite. Keep writes inside one folder.
  5. Expose resources. Each note as notes://{id}, so the app can attach it.
  6. Add a prompt. summarize_notes for the user to trigger.
  7. Pick the transport. stdio for local use.
  8. Test. Unit-test the logic and the JSON-RPC shapes (see below).
  9. Connect and review. Add to the host config, test with approvals on.
flowchart TD
  H[Host + LLM] -- stdio --> S[Notes MCP server]
  S --> T[Tools: create / list / search]
  S --> R[Resources: notes://id]
  S --> P[Prompt: summarize_notes]
  T --> D[(Notes file or SQLite)]
  R --> D

Worked example

The plan, as a story of one note.

  1. The user says: "Save a note called ideas with the text buy milk."
  2. The model calls create_note with title = ideas and body = buy milk.
  3. The server checks the title length and type. It rejects anything unexpected, then writes the note inside one folder only.
  4. The server replies isError false with a short confirmation.
  5. Later, the app attaches the note as the resource notes://ideas, or the user triggers the summarize_notes prompt.

Notice what is missing: no raw SQL, no shell command, no path from the user. Every step the model can reach is small and checked.

Code example

Step A: the real starting point. Copy code/hello-mcp-server. It already has registration, zod validation, annotations and stdio.

Step B: add a tool. Same SDK calls as the hello file; the storage is yours:

const notes = new Map<string, string>();

server.registerTool(
  "create_note",
  {
    title: "Create note",
    description: "Save a note with a title and body.",
    inputSchema: { title: z.string().min(1).max(100), body: z.string().max(5000) },
    annotations: { readOnlyHint: false, destructiveHint: false },
  },
  async ({ title, body }) => {
    notes.set(title, body);
    return { content: [{ type: "text", text: `Saved "${title}"` }] };
  }
);

Step C: report failures as results.

async ({ query }) => {
  const hits = [...notes.keys()].filter((t) => t.includes(query));
  if (hits.length === 0)
    return { isError: true, content: [{ type: "text", text: "No notes match." }] };
  return { content: [{ type: "text", text: hits.join("\n") }] };
}

Step D: add a resource and a prompt using registerResource and registerPrompt as shown in topic 05 and topic 06. Check the SDK docs for your version.

Step E: build and run.

cd 06-mcp/code/hello-mcp-server
npm ci && npm run build
node dist/index.js        # waits on stdin; logs only to stderr

Step F: test the protocol offline. Run code/mcp_mini_jsonrpc.py to see every message shape, and run its tests:

python -m pytest -q 06-mcp/code

Step G: connect. Use the claude_desktop_config.json from topic 03. You can also try the official MCP Inspector (see the docs).

In real projects (Python MCPServer, called FastMCP in mcp 1.x):

from mcp.server.mcpserver import MCPServer

mcp = MCPServer("notes")
notes: dict[str, str] = {}

@mcp.tool()
def create_note(title: str, body: str) -> str:
    """Save a note with a title and body."""
    notes[title] = body
    return f"Saved {title}"

@mcp.resource("notes://{title}")
def read_note(title: str) -> str:
    return notes[title]

@mcp.prompt()
def summarize_notes() -> str:
    return "Summarize my notes in five bullets."

if __name__ == "__main__":
    mcp.run()

Security checklist for your server

When to use it / when not to

Common mistakes

Recap

If you remember only three things:

Interview questions

  1. How would you design a notes server safely?

<details><summary>Answer</summary>Narrow tools, validated inputs, storage limited to one folder, read-only hints where true, approvals on writes, logs to stderr.</details>

  1. How do you test a server without an LLM?

<details><summary>Answer</summary>Unit-test the logic and send raw JSON-RPC requests (initialize, tools/list, tools/call), or use the MCP Inspector.</details>

  1. When would you move from stdio to Streamable HTTP?

<details><summary>Answer</summary>When many users or remote hosts need one shared server. Then add HTTPS and authorization.</details>

Go deeper

Prev: 06-mcp-prompts.md · Next: Phase 7 · Back to phase: Phase 6