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.
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.
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.
Your App
Manages servers
Exposes tools
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.
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.
{
"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"
}
}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.
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
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.
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.
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.
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());
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.
// 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"); });
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.
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());
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.
{
"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.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.
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.
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());
Production, Testing & Portfolio
Getting your MCP server production-ready, writing tests, and presenting it in your portfolio alongside your RAG system.
| MCP Server Idea | Tools | Connects To | Portfolio Value |
|---|---|---|---|
| RAG Wrapper | upload, query, stats | Your FastAPI backend | High — shows full-stack integration |
| File System | read, write, list | Local files | Medium — classic utility |
| SQLite Database | query, execute, schema | Local .db file | High — shows DB + AI |
| GitHub | get_issues, create_pr, search_code | GitHub API | Very High — real DevOps use |
| ML Model API | predict, explain_prediction | Your sklearn model API | Very High — combines everything |
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