Home / Development / Claude Code, part 4: MCP, using and building servers

Claude Code, part 4: MCP, using and building servers

Oct 18, 2026Noteclaude-code · mcp · python · typescript
Series Getting started with Claude Code Part 4 of 5
  1. Claude Code, part 1: Install and first session
  2. Claude Code, part 2: The everyday workflow
  3. Claude Code, part 3: Skills, subagents, hooks and plugins
  4. Claude Code, part 4: MCP, using and building servers
  5. Claude Code, part 5: RAG, giving Claude your own knowledge

Skills and hooks shape how Claude works. MCP decides what it can reach. This part explains the protocol, connects ready-made servers, then builds the same server twice: in Python and in TypeScript. Both versions were run and tested against MCP SDK 2.3 and Claude Code 2.1.302.

What MCP is

The Model Context Protocol is an open standard for connecting AI applications to tools and data. Think of it as USB-C for AI: build a server once and it works in any MCP client, including Claude Code, the Claude apps, IDEs and other agents.

┌────────────── host: Claude Code ──────────────┐
│  MCP client ──┐   MCP client ──┐               │
└───────────────┼────────────────┼───────────────┘
                │ stdio          │ HTTP
         ┌──────▼──────┐  ┌──────▼──────────┐
         │ local server │  │ remote server   │
         │ (your notes) │  │ (GitHub, Sentry)│
         └─────────────┘  └─────────────────┘

A server can offer three kinds of things:

Primitive What it is In Claude Code
Tools Functions the model can call, such as search_notes(query) Claude calls them as needed (with your permission)
Resources Readable data identified by a URI, such as notes://todo Attach with an @server: mention
Prompts Reusable prompt templates with arguments Run as /mcp__server__prompt

And two main transports:

Transport Use for
stdio A local process Claude Code starts and talks to over stdin and stdout. Your own scripts, local files, CLIs.
HTTP (streamable HTTP) A remote server at a URL. Hosted SaaS servers; supports OAuth.

SSE is deprecated; use HTTP where a server offers it.

Part A: Connect existing servers

Add a remote (HTTP) server

claude mcp add --transport http notion https://mcp.notion.com/mcp
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp

Then, inside a session, run /mcp and complete the browser sign-in for servers that use OAuth. Tokens are stored and refreshed for you. For APIs that use a static key, pass a header:

claude mcp add --transport http my-api https://api.example.com/mcp \
  --header "Authorization: Bearer $MY_API_TOKEN"

Add a local (stdio) server

claude mcp add --env AIRTABLE_API_KEY=your_key --transport stdio airtable \
  -- npx -y airtable-mcp-server

Everything after -- is the server’s own command line. Without the separator, a server flag like --port would be read as a Claude Code option.

Scopes: who gets the server

Scope Flag Stored in Shared
Local (default) --scope local ~/.claude.json, under this project No
Project --scope project .mcp.json in the repo Yes, via git
User --scope user ~/.claude.json No; all your projects

A team-shared .mcp.json supports environment variables, so secrets stay out of git:

{
  "mcpServers": {
    "api-server": {
      "type": "http",
      "url": "${API_BASE_URL:-https://api.example.com}/mcp",
      "headers": { "Authorization": "Bearer ${API_KEY}" }
    }
  }
}

For safety, Claude Code asks you to approve project-scoped servers from .mcp.json before using them.

Manage servers

claude mcp list            # all servers, with a live health check
claude mcp get notion      # details for one
claude mcp remove notion
claude mcp add-json weather '{"type":"http","url":"https://api.weather.example/mcp"}'
claude mcp add-from-claude-desktop   # import servers you set up in Claude Desktop

MCP tools follow permission rules like any other tool. Their names are mcp__<server>__<tool>:

{ "permissions": { "allow": ["mcp__notes__search_notes", "mcp__github__get_*"] } }

Large tool results are capped (25,000 tokens by default; adjust with MAX_MCP_OUTPUT_TOKENS), and tool definitions load on demand through tool search, so having many servers doesn’t fill your context.

Part B: Build your own server

The example is a notes server: it gives Claude a folder of Markdown notes, with three tools (search_notes, read_note, add_note), one resource template (notes://{name}) and one prompt (summarize_note). It’s small enough to read in one sitting and real enough to use daily.

The one rule of stdio servers: never write to stdout. stdout carries the protocol’s JSON-RPC messages, and a single stray print() or console.log() breaks the connection. Log to stderr.

Python

The Python SDK (mcp 2.x) turns type hints and docstrings into tool schemas automatically.

uv init notes-mcp && cd notes-mcp
uv add "mcp[cli]"
# or, without uv:  python -m venv .venv && .venv/bin/pip install "mcp[cli]"

notes_server.py:

"""A small MCP server that gives Claude a folder of Markdown notes.

Tools:     search_notes, read_note, add_note
Resource:  notes://{name}
Prompt:    summarize_note
"""
import logging
import os
from pathlib import Path

from mcp.server import MCPServer

# stdio servers talk JSON-RPC over stdout, so log to stderr only.
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("notes")

NOTES_DIR = Path(os.environ.get("NOTES_DIR", "~/notes")).expanduser()
NOTES_DIR.mkdir(parents=True, exist_ok=True)

mcp = MCPServer("notes", instructions="Personal Markdown notes. Search before reading.")


def _path(name: str) -> Path:
    """Resolve a note name to a file inside NOTES_DIR (no path traversal)."""
    p = (NOTES_DIR / f"{Path(name).stem}.md").resolve()
    if p.parent != NOTES_DIR.resolve():
        raise ValueError("invalid note name")
    return p


@mcp.tool()
def search_notes(query: str, limit: int = 5) -> list[dict]:
    """Search notes by keyword. Returns matching note names with a snippet.

    Args:
        query: Words to look for (case-insensitive).
        limit: Maximum number of results.
    """
    words = query.lower().split()
    hits = []
    for f in sorted(NOTES_DIR.glob("*.md")):
        text = f.read_text(encoding="utf-8")
        score = sum(text.lower().count(w) for w in words)
        if score:
            first = next((ln for ln in text.splitlines() if any(w in ln.lower() for w in words)), "")
            hits.append({"name": f.stem, "score": score, "snippet": first.strip()[:200]})
    log.info("search %r -> %d hits", query, len(hits))
    return sorted(hits, key=lambda h: -h["score"])[:limit]


@mcp.tool()
def read_note(name: str) -> str:
    """Return the full Markdown of one note.

    Args:
        name: Note name as returned by search_notes (without .md).
    """
    p = _path(name)
    if not p.exists():
        return f"No note called {name!r}."
    return p.read_text(encoding="utf-8")


@mcp.tool()
def add_note(title: str, body: str) -> str:
    """Create or overwrite a note.

    Args:
        title: Short title; also used as the file name.
        body: Markdown content.
    """
    name = "-".join(title.lower().split())
    _path(name).write_text(f"# {title}\n\n{body}\n", encoding="utf-8")
    return f"Saved note {name!r}."


@mcp.resource("notes://{name}", mime_type="text/markdown")
def note_resource(name: str) -> str:
    """A note, readable as an MCP resource."""
    return read_note(name)


@mcp.prompt()
def summarize_note(name: str) -> str:
    """Prompt template: summarize a note into action items."""
    return f"Read the note '{name}' with read_note, then list its key points and any action items."


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

Things worth noticing:

  • Docstrings are the interface. Claude decides when to call a tool from its name, description and argument docs, so write them for a reader who has never seen your code.
  • Return types become structured output. search_notes returns a list of dicts, and clients receive it as JSON.
  • Validate inputs. _path() keeps ../../etc/passwd from escaping the notes folder.

Register it with Claude Code. Use absolute paths, because Claude Code starts the process from wherever you launch it:

claude mcp add notes --env NOTES_DIR=$HOME/notes \
  -- uv --directory /absolute/path/to/notes-mcp run notes_server.py

TypeScript

The TypeScript SDK 2.x ships the server as @modelcontextprotocol/server, with zod for input schemas.

mkdir notes-mcp-ts && cd notes-mcp-ts
npm init -y && npm pkg set type=module scripts.build="tsc"
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src

tsconfig.json:

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

src/index.ts:

// A small MCP server that gives Claude a folder of Markdown notes (TypeScript).
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { homedir } from "node:os";

const NOTES_DIR = resolve((process.env.NOTES_DIR ?? "~/notes").replace(/^~/, homedir()));
mkdirSync(NOTES_DIR, { recursive: true });

// Keep note names inside NOTES_DIR (no "../" tricks).
const notePath = (name: string) => join(NOTES_DIR, basename(name).replace(/\.md$/, "") + ".md");
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });

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

  server.registerTool(
    "search_notes",
    {
      description: "Search notes by keyword. Returns matching note names with a snippet.",
      inputSchema: z.object({
        query: z.string().describe("Words to look for (case-insensitive)"),
        limit: z.number().int().min(1).max(20).default(5),
      }),
    },
    async ({ query, limit }) => {
      const words = query.toLowerCase().split(/\s+/).filter(Boolean);
      const hits = readdirSync(NOTES_DIR)
        .filter((f) => f.endsWith(".md"))
        .map((f) => {
          const body = readFileSync(join(NOTES_DIR, f), "utf8");
          const lower = body.toLowerCase();
          const score = words.reduce((n, w) => n + lower.split(w).length - 1, 0);
          const snippet = body.split("\n").find((l) => words.some((w) => l.toLowerCase().includes(w))) ?? "";
          return { name: f.slice(0, -3), score, snippet: snippet.trim().slice(0, 200) };
        })
        .filter((h) => h.score > 0)
        .sort((a, b) => b.score - a.score)
        .slice(0, limit);
      return text(JSON.stringify(hits, null, 2));
    },
  );

  server.registerTool(
    "read_note",
    {
      description: "Return the full Markdown of one note.",
      inputSchema: z.object({ name: z.string().describe("Note name as returned by search_notes") }),
    },
    async ({ name }) => {
      const p = notePath(name);
      return text(existsSync(p) ? readFileSync(p, "utf8") : `No note called "${name}".`);
    },
  );

  server.registerTool(
    "add_note",
    {
      description: "Create or overwrite a note.",
      inputSchema: z.object({ title: z.string(), body: z.string().describe("Markdown content") }),
    },
    async ({ title, body }) => {
      const name = title.toLowerCase().split(/\s+/).join("-");
      writeFileSync(notePath(name), `# ${title}\n\n${body}\n`);
      return text(`Saved note "${name}".`);
    },
  );

  server.registerResource(
    "note",
    new ResourceTemplate("notes://{name}", { list: undefined }),
    { title: "Note", mimeType: "text/markdown" },
    async (uri, { name }) => ({
      contents: [{ uri: uri.href, text: readFileSync(notePath(String(name)), "utf8") }],
    }),
  );

  server.registerPrompt(
    "summarize_note",
    {
      description: "Summarize a note into key points and action items",
      argsSchema: z.object({ name: z.string() }),
    },
    ({ name }) => ({
      messages: [{ role: "user" as const, content: { type: "text" as const,
        text: `Read the note '${name}' with read_note, then list its key points and any action items.` } }],
    }),
  );

  return server;
}

// stdout carries the protocol: log with console.error only.
serveStdio(createServer, { onerror: (err) => console.error("notes server error:", err) });
console.error(`notes MCP server running on stdio (${NOTES_DIR})`);

Notes on the TypeScript version:

  • serveStdio takes a factory (createServer), not an instance. It negotiates the protocol version with each client, so return a fresh server on every call.
  • Tool results are content blocks ({ content: [{ type: "text", text }] }).
  • The zod schema validates arguments before your handler runs, so limit: 500 is rejected automatically.

Build and register:

npm run build
claude mcp add notes-ts --env NOTES_DIR=$HOME/notes -- node /absolute/path/to/notes-mcp-ts/build/index.js

Test before you trust it

claude mcp list
#  notes: … - ✓ Connected

The MCP Inspector lets you call tools by hand without involving Claude. Run npx @modelcontextprotocol/inspector for the web UI, or use the CLI:

npx @modelcontextprotocol/inspector --cli node build/index.js --protocol-era modern --method tools/list

Use it from Claude Code

> search my notes for anything about caddy and summarise it
  ● notes - search_notes (query: "caddy")
  ● notes - read_note (name: "caddy-tips")
  Your note says to use handle_errors with a rewrite to /404.html …

> save a note called "Deploy checklist" with the steps we just used

> @notes:notes://caddy-tips what's missing from this?

> /mcp__notes__summarize_note caddy-tips

Design tips for good servers

  1. Few, well-named tools. search_notes plus read_note beats one do-everything tool. Name tools for the user’s intent, not your API’s endpoints.
  2. Descriptions are prompts. Say when to use the tool and what comes back. Add an instructions string to the server for overall guidance.
  3. Return small, relevant results. Paginate, cap list sizes, return snippets plus IDs and offer a “read more” tool. Large outputs waste context and can hit size limits.
  4. Fail helpfully. Return a readable message (“No note called X. Try search_notes.”) rather than a stack trace, so Claude can recover.
  5. Least privilege. Read-only by default; separate tools for writes so permission rules can treat them differently.
  6. stdio for local, HTTP for shared. Run the server remotely over HTTP, with auth, when a team or several machines need it.

Next

Part 5 builds on this: a retrieval-augmented generation (RAG) pipeline over your own documents, exposed to Claude Code as an MCP tool.