The Next Step After RAG

Build Your Own MCP Server
From Scratch

MCP (Model Context Protocol) is how you connect AI models like Claude to real data, real tools, and real systems. You already built a RAG system — now build the protocol layer that makes Claude actually useful in the real world.

10
Chapters
TypeScript
Language
4
Real Projects
RAG+
Connects to Yours
Chapter 01

What Is MCP and Why Should You Care?

MCP stands for Model Context Protocol. It's an open standard created by Anthropic that defines a clean, consistent way for AI models to connect to external data sources and tools. Think of it as a universal adapter — like USB-C, but for AI.

💡 The Core Idea

Without MCP: every app that wants Claude to access a database, file system, or API has to build a custom integration from scratch. With MCP: you write an MCP server once, and any MCP-compatible AI client (Claude Desktop, your own app, cursor, etc.) can use it immediately — no changes required on the AI side.

The Problem MCP Solves
Before MCP, connecting Claude to your database required custom prompt engineering, manual data fetching, and fragile string parsing. Every new data source meant rebuilding the integration. MCP standardizes this completely.
Core Concept
MCP vs Function Calling / Tool Use
Tool use / function calling is Claude's ability to call a tool. MCP is the protocol that defines how tools are discovered, described, and invoked. MCP tools ARE function calls — but the discovery and lifecycle is standardized.
Key Distinction
What You Can Build
File system access. Database queries. Web scraping. GitHub integration. Slack messages. Calendar access. Custom ML model inference. Anything you can code, you can expose as an MCP server — and Claude can use it.
Limitless
MCP + Your RAG System
You already built a RAG system with a FastAPI backend. You can wrap your /upload and /query endpoints as MCP tools so Claude can upload documents and query them natively from Claude Desktop. Chapter 9 covers exactly this.
Your Project
Interview Q
"What is MCP and how does it differ from just calling an API?"
MCP is a standardized protocol for AI-tool communication. When you call an API, you hardcode the URL, parameters, and response parsing. With MCP, the server self-describes its capabilities using a standard schema — the AI client discovers them automatically, understands what inputs are needed, and handles the interaction protocol. It's the difference between bespoke integrations and a plug-and-play standard. Once you write an MCP server, any MCP-compatible client works with it.
Chapter 02

Architecture & Core Concepts

MCP has three primitive types: Tools, Resources, and Prompts. Understanding these three is the entire mental model you need. Everything else is implementation.

MCP Architecture — How It Flows
Host App
Claude Desktop
Your App
MCP Client
Built into host
Manages servers
MCP Server
Your code
Exposes tools
Your Data
DB / Files
APIs / ML

Transport layer: STDIO (local processes) or HTTP+SSE (remote servers). The MCP client in Claude Desktop manages connections to all your registered MCP servers.

🔧 Tools
Actions Claude can invoke. Like a function call: has a name, description, input schema (JSON Schema), and returns text. Examples: search_database(query), read_file(path), send_email(to, body). Claude decides when to call them.
Primary Primitive
📄 Resources
Static or dynamic data Claude can read. Like files — they have a URI and content. Examples: file:///docs/readme.md, db://customers/schema. Claude can read resources directly into context. Like RAG but fully structured.
Data Access
💬 Prompts
Reusable prompt templates with dynamic arguments. Define them once in your server, use them from any client. Example: summarize_document(doc_id, style='bullet'). Useful for standardizing how Claude approaches common tasks.
Prompt Engineering
Transport: STDIO vs HTTP
STDIO: server runs as a subprocess, communicates via stdin/stdout. Best for local tools and Claude Desktop. HTTP+SSE: server runs as a web service. Best for remote servers, team use, and production deployment. Both use JSON-RPC 2.0 underneath.
Protocol Layer
JSON-RPC 2.0 Underneath
All MCP messages are JSON-RPC 2.0: {jsonrpc:"2.0", method:"tools/call", params:{...}, id:1}. You never write this manually — the SDK handles it. But knowing it helps when debugging with raw logs.
Under the Hood
Lifecycle: initialize → list → call
Client sends initialize request → server responds with capabilities → client lists tools/resources → user prompt triggers tool call → server executes and returns result → Claude synthesizes response. Clean, predictable lifecycle.
Lifecycle
Chapter 03

Environment Setup

MCP servers are most commonly written in TypeScript using the official @modelcontextprotocol/sdk. You can also use Python. This guide uses TypeScript — it has the best SDK support and matches what Anthropic uses internally.

1
Install Node.js 18+
MCP requires Node.js 18 or higher. Check: node --version. Download from nodejs.org if needed. Use nvm for version management.
2
Create project
mkdir my-mcp-server && cd my-mcp-server && npm init -y
3
Install MCP SDK
npm install @modelcontextprotocol/sdk zod. Zod is for input validation — same role as Pydantic in your Python backend.
4
Install TypeScript tooling
npm install -D typescript @types/node tsx. tsx lets you run TypeScript directly without a build step during development.
5
Configure tsconfig.json
Set target to ES2022, module to Node16, and enable strict mode. The MCP SDK requires modern JS features.
package.json
json
{
  "name": "my-mcp-server",
  "type": "module",
  "scripts": {
    "dev":   "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "typescript": "^5.3.0",
    "@types/node": "^20.0.0",
    "tsx": "^4.0.0"
  }
}
Chapter 04

Your First MCP Server — Hello Tool

The simplest possible MCP server: one tool that echoes a message back. This teaches you the complete skeleton. Every MCP server you build follows this exact pattern.

src/index.ts — Minimal MCP Server
typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

// ─── 1. Create the server ─────────────────────────────────
// Name and version are shown in Claude Desktop's server list
const server = new Server(
  { name: "my-first-server", version: "1.0.0" },
  { capabilities: { tools: {} } }   // declare what we support
);

// ─── 2. List available tools ──────────────────────────────
// Claude calls this to discover what your server can do
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [{
    name: "greet",
    description: "Say hello to someone. Use when the user wants a greeting.",
    inputSchema: {
      type: "object",
      properties: {
        name: { type: "string", description: "The person to greet" }
      },
      required: ["name"]
    }
  }]
}));

// ─── 3. Handle tool calls ─────────────────────────────────
// Claude calls this when it decides to use a tool
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "greet") {
    const { name } = request.params.arguments as { name: string };
    return {
      content: [{ type: "text", text: `Hello, ${name}! 👋 MCP is working.` }]
    };
  }
  throw new Error(`Unknown tool: ${request.params.name}`);
});

// ─── 4. Start the server ──────────────────────────────────
// STDIO transport: Claude Desktop runs this as a subprocess
const transport = new StdioServerTransport();
await server.connect(transport);
// Server is now running — waiting for JSON-RPC messages on stdin
✅ The Pattern to Remember

Every MCP server does exactly 4 things: (1) create Server with capabilities, (2) handle ListTools to describe available tools, (3) handle CallTool to execute them, (4) connect to a transport. That's the complete pattern — no matter how complex your server gets.

Chapter 05

Real MCP Server — File System Tools

A practical MCP server that lets Claude read, write, and list files on your computer. This is one of the most useful real-world MCP servers and teaches you input validation, error handling, and security patterns.

🔒 Security First

Any MCP tool that touches the file system MUST validate paths. Never allow arbitrary path traversal. Restrict to a defined root directory. This applies to any MCP server that touches sensitive systems — database, network, etc.

src/filesystem-server.ts
typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import * as fs from "node:fs/promises";
import * as path from "node:path";

// Only allow access inside this directory (security!)
const ALLOWED_ROOT = path.resolve("./workspace");

function safePath(userPath: string): string {
  const resolved = path.resolve(ALLOWED_ROOT, userPath);
  if (!resolved.startsWith(ALLOWED_ROOT)) {
    throw new Error("Path traversal not allowed");
  }
  return resolved;
}

const server = new Server(
  { name: "filesystem-server", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "read_file",
      description: "Read the contents of a file. Returns the full text content.",
      inputSchema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }
    },
    {
      name: "write_file",
      description: "Write content to a file. Creates it if it doesn't exist.",
      inputSchema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] }
    },
    {
      name: "list_files",
      description: "List all files and folders in a directory.",
      inputSchema: { type: "object", properties: { directory: { type: "string", default: "." } } }
    }
  ]
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;
  
  try {
    if (name === "read_file") {
      const content = await fs.readFile(safePath(args.path as string), "utf-8");
      return { content: [{ type: "text", text: content }] };
    }
    if (name === "write_file") {
      await fs.writeFile(safePath(args.path as string), args.content as string);
      return { content: [{ type: "text", text: `✅ Written to ${args.path}` }] };
    }
    if (name === "list_files") {
      const dir = safePath((args.directory as string) ?? ".");
      const entries = await fs.readdir(dir, { withFileTypes: true });
      const list = entries.map(e => `${e.isDirectory() ? "📁" : "📄"} ${e.name}`).join("\n");
      return { content: [{ type: "text", text: list }] };
    }
  } catch (err: any) {
    // Return errors as content — Claude sees the error and can explain it
    return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
  }
  throw new Error(`Unknown tool: ${name}`);
});

await server.connect(new StdioServerTransport());
Chapter 06

Resources & Prompts

Tools are for actions. Resources are for data. Prompts are for reusable templates. Adding all three makes your MCP server a complete context provider for Claude.

Resources — Static URI-addressable Data
Define a URI scheme (e.g. file://, db://, config://) and expose data under it. Claude can read resources directly into its context window. Perfect for schemas, documentation, configuration files, or database records.
Data Access
Resource Templates
Dynamic resources with URI templates: file:///{path}, db://customers/{id}. The client fills in the placeholders. Enables Claude to browse through data structures — "show me customer {id}".
Dynamic
Prompts — Reusable Templates
Pre-defined prompt patterns with arguments. Listed just like tools. Example: a "code_review" prompt that takes language and code arguments and returns a structured system prompt for reviewing code in that language.
Templates
src/resources-example.ts — Adding Resources & Prompts
typescript
// Add resources support to server capabilities:
const server = new Server(
  { name: "full-server", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {}, prompts: {} } }  // ← all three
);

// ─── Resources ────────────────────────────────────────────
import { ListResourcesRequestSchema, ReadResourceRequestSchema,
         ListPromptsRequestSchema, GetPromptRequestSchema } from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [{
    uri: "config://app/settings",
    name: "Application Settings",
    description: "Current app configuration",
    mimeType: "application/json"
  }]
}));

server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
  if (req.params.uri === "config://app/settings") {
    return { contents: [{ uri: req.params.uri, mimeType: "application/json",
      text: JSON.stringify({ model: "gemini-2.0-flash", top_k: 5, version: "1.0.0" }, null, 2) }] };
  }
  throw new Error("Resource not found");
});

// ─── Prompts ──────────────────────────────────────────────
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
  prompts: [{ name: "summarize", description: "Summarize a document",
    arguments: [{ name: "style", description: "bullet | paragraph | executive", required: false }] }]
}));

server.setRequestHandler(GetPromptRequestSchema, async (req) => {
  if (req.params.name === "summarize") {
    const style = req.params.arguments?.style ?? "paragraph";
    return { messages: [{ role: "user", content: { type: "text",
      text: `Please summarize the provided document in ${style} format. Be concise and accurate.` }}] };
  }
  throw new Error("Prompt not found");
});
Chapter 07

Database MCP Server

The most practical MCP server for portfolios and real work: expose a SQLite database to Claude. Claude can then write and run SQL queries, explore schemas, and analyze data — all through natural language.

src/database-server.ts
typescript
import Database from "better-sqlite3";  // npm install better-sqlite3
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema,
         ListResourcesRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";

const db = new Database("./data.db");
const server = new Server(
  { name: "sqlite-server", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

// Expose database schema as a Resource so Claude can see the structure
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [{ uri: "db://schema", name: "Database Schema", mimeType: "text/plain" }]
}));

server.setRequestHandler(ReadResourceRequestSchema, async () => {
  const tables = db.prepare("SELECT name, sql FROM sqlite_master WHERE type='table'").all();
  const schema = tables.map((t: any) => t.sql).join("\n\n");
  return { contents: [{ uri: "db://schema", mimeType: "text/plain", text: schema }] };
});

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "query",
      description: "Run a SELECT SQL query. Read-only. Use for data retrieval and analysis.",
      inputSchema: { type: "object", properties: { sql: { type: "string" } }, required: ["sql"] }
    },
    {
      name: "execute",
      description: "Run INSERT, UPDATE, or DELETE SQL. Modifies data — use carefully.",
      inputSchema: { type: "object", properties: { sql: { type: "string" } }, required: ["sql"] }
    }
  ]
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;
  const sql = args.sql as string;
  try {
    if (name === "query") {
      if (!sql.trim().toUpperCase().startsWith("SELECT")) throw new Error("Only SELECT queries allowed");
      const rows = db.prepare(sql).all();
      return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
    }
    if (name === "execute") {
      const result = db.prepare(sql).run();
      return { content: [{ type: "text", text: `Affected ${result.changes} rows` }] };
    }
  } catch (e: any) {
    return { content: [{ type: "text", text: `SQL Error: ${e.message}` }], isError: true };
  }
  throw new Error(`Unknown tool`);
});

await server.connect(new StdioServerTransport());
Chapter 08

Connect to Claude Desktop

Claude Desktop is the fastest way to test your MCP server. Once configured, Claude will automatically discover your tools and use them in conversations.

1
Build your server
npm run build → produces dist/index.js. This is what Claude Desktop will execute.
2
Find Claude Desktop config
Mac: ~/Library/Application Support/Claude/claude_desktop_config.json. Windows: %APPDATA%\Claude\claude_desktop_config.json
3
Add your server to config
See the JSON config below. The key is your server name, value has command and args pointing to your built file.
4
Restart Claude Desktop
Fully quit and reopen. You should see your server in Settings → MCP Servers. A green dot means it connected successfully.
5
Test it
Type a message that should trigger your tool. For the file server: "List the files in my workspace." Claude should call list_files automatically.
claude_desktop_config.json
json
{
  "mcpServers": {
    "filesystem": {
      "command": "node",
      "args": ["/absolute/path/to/my-mcp-server/dist/index.js"],
      "env": {}
    },
    "database": {
      "command": "node",
      "args": ["/absolute/path/to/database-server/dist/index.js"]
    },
    "rag-system": {
      "command": "node",
      "args": ["/path/to/rag-mcp/dist/index.js"],
      "env": { "RAG_API_URL": "http://localhost:8000" }
    }
  }
}
// ↑ Register as many MCP servers as you want. All run simultaneously.
Chapter 09

MCP Wrapper for Your RAG System

This is where your two projects connect. Build an MCP server that wraps your FastAPI RAG backend — so Claude Desktop can upload documents and ask questions natively, without any UI.

💡 The Power Move

You already have a working RAG system with /upload and /query endpoints. This MCP server is just a thin adapter that calls those endpoints. Claude Desktop users can then say "Upload this document" or "What does my uploaded research say about X?" and Claude handles everything.

src/rag-mcp-server.ts — Connects to Your RAG FastAPI Backend
typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { readFile } from "node:fs/promises";

const RAG_URL = process.env.RAG_API_URL ?? "http://localhost:8000/api";

const server = new Server(
  { name: "rag-system", version: "1.0.0" },
  { capabilities: { tools: {} } }
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "upload_document",
      description: "Upload a local file to the RAG system for indexing. Use when the user wants to add a document for Q&A.",
      inputSchema: { type: "object",
        properties: { file_path: { type: "string", description: "Absolute path to the file to upload" } },
        required: ["file_path"] }
    },
    {
      name: "query_documents",
      description: "Ask a question about uploaded documents. Returns an AI-generated answer with sources.",
      inputSchema: { type: "object",
        properties: {
          question: { type: "string", description: "The question to answer from the documents" },
          provider: { type: "string", enum: ["openai", "gemini", "puter"], default: "openai" }
        },
        required: ["question"] }
    },
    {
      name: "get_stats",
      description: "Get information about how many documents and chunks are indexed.",
      inputSchema: { type: "object", properties: {} }
    }
  ]
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const { name, arguments: args } = req.params;

  if (name === "upload_document") {
    const filePath = args.file_path as string;
    const fileContent = await readFile(filePath);
    const fileName = filePath.split("/").pop()!;
    const form = new FormData();
    form.append("file", new Blob([fileContent]), fileName);
    const res = await fetch(`${RAG_URL}/upload`, { method: "POST", body: form });
    const data = await res.json();
    return { content: [{ type: "text", text: `✅ Uploaded "${fileName}" — ${data.chunks_created} chunks indexed.` }] };
  }

  if (name === "query_documents") {
    const res = await fetch(`${RAG_URL}/query`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ query: args.question, provider: args.provider ?? "openai", top_k: 5 })
    });
    const data = await res.json();
    return { content: [{ type: "text", text: `${data.answer}\n\nSources: ${data.sources?.join(", ")}\nModel: ${data.model} | ${data.processing_time_ms}ms` }] };
  }

  if (name === "get_stats") {
    const res = await fetch(`${RAG_URL}/stats`);
    const data = await res.json();
    return { content: [{ type: "text", text: `RAG System: ${data.total_documents} docs, ${data.total_chunks} chunks indexed.` }] };
  }

  throw new Error(`Unknown tool: ${name}`);
});

await server.connect(new StdioServerTransport());
Chapter 10

Production, Testing & Portfolio

Getting your MCP server production-ready, writing tests, and presenting it in your portfolio alongside your RAG system.

Error Handling Patterns
Always return errors as content with isError:true — don't throw unhandled exceptions. Claude sees the error message and can handle it gracefully. Log errors to stderr (stdout is reserved for JSON-RPC messages).
Production
Testing with MCP Inspector
npx @modelcontextprotocol/inspector node dist/index.js opens a web UI where you can call your tools interactively, see request/response JSON, and debug without Claude Desktop. Your first debug tool.
Testing
HTTP Transport for Production
Switch from StdioServerTransport to SSEServerTransport for a web-accessible server. Enables team access, remote deployment, and integration with any HTTP-capable client. Use with Express.js.
Remote Deploy
Portfolio README Structure
Show: what Claude can do with your MCP server (natural language examples). Architecture diagram. Quick start (3 commands). Which tools exist and what they do. Demo GIF of Claude using a tool. Link to your RAG system.
Portfolio
Tool Descriptions — The Secret to Good MCP
The most important part of any MCP server is the tool description string. Claude decides WHEN to call a tool based entirely on this description. Be explicit: "Use when the user wants to..." and "Returns..." Bad descriptions = Claude never uses your tool.
Critical
Combine with RAG + ML Models
The portfolio trifecta: RAG system for document Q&A + ML models for analysis (via FastAPI) + MCP server connecting them to Claude Desktop. Three separate projects, all connected. Demonstrates full-stack AI engineering.
Portfolio Goal
MCP Server IdeaToolsConnects ToPortfolio Value
RAG Wrapperupload, query, statsYour FastAPI backendHigh — shows full-stack integration
File Systemread, write, listLocal filesMedium — classic utility
SQLite Databasequery, execute, schemaLocal .db fileHigh — shows DB + AI
GitHubget_issues, create_pr, search_codeGitHub APIVery High — real DevOps use
ML Model APIpredict, explain_predictionYour sklearn model APIVery High — combines everything
🎓 MCP Mastery Checkpoint

You've mastered MCP when you can:

  • Explain the difference between Tools, Resources, and Prompts
  • Build an MCP server from scratch in under 30 minutes
  • Connect it to Claude Desktop and test with MCP Inspector
  • Wrap your RAG FastAPI backend as MCP tools
  • Write tool descriptions that Claude reliably uses
  • Handle errors gracefully so Claude can explain them to users
  • Explain MCP vs direct API calls in an interview
Interview Q
"How would you make Claude able to query your company's database safely?"
I'd build an MCP server that wraps the database. The server exposes two tools: a read-only query tool (validates that SQL starts with SELECT, parameterizes inputs to prevent injection) and a schema reader (so Claude understands the table structure). The MCP server runs locally with STDIO transport — it never exposes the database to the internet. Claude Desktop connects to it and can now answer natural language questions by writing and executing SQL queries through the MCP layer. The tool description explicitly says read-only, so Claude won't attempt destructive operations.