Building AI Agents with Claude
Track 2: Tool Use Module 8 of 30
60-75 min Intermediate Prereqs: M05, M06

M07: MCP — Model Context Protocol

The universal standard that connects AI models to any data source or tool — build once, integrate everywhere.

Learning Objectives

  • Explain what MCP is and why it transforms tool integration from N×M to N+M
  • Describe the three MCP primitives: Resources, Tools, and Prompts
  • Build a functional MCP server with tools, resources, and prompt templates
  • Configure Claude Desktop and programmatic clients to connect to MCP servers
  • Choose the right MCP primitive for a given integration scenario

What Is MCP? The "USB-C for AI"

Everyday Analogy

Remember when every phone had its own charger? Micro-USB, Lightning, barrel jacks — a tangled drawer of cables. Then USB-C arrived: one universal plug for everything. MCPModel Context Protocol — an open standard by Anthropic that defines how AI applications communicate with external data sources and tools through a unified interface. is that USB-C moment for AI. Before MCP, every AI model needed a custom integration for every tool. MCP replaces that chaos with a single, universal standard.

Technical Definition

The Model Context Protocol (MCP) is an open standard created by Anthropic that defines how AI applications talk to external data sources and tools. There are two sides to every MCP conversation. First, the clientAn MCP client is the AI-facing application (like Claude Desktop or Claude Code) that connects to MCP servers to access their tools, resources, and prompts. — that's the AI-facing application, like Claude Desktop or Claude Code. Second, the serverAn MCP server is a program that implements the MCP protocol to expose tools, resources, and/or prompts to any compatible client. — a small program you write that exposes your data or actions.

How do they actually communicate? They exchange small JSON messages using a format called JSON-RPC 2.0A lightweight remote procedure call protocol using JSON. It defines request/response message formats with method names, parameters, and result/error fields. — essentially named method calls with parameters, sent as JSON. These messages travel over one of two transports. For local servers, there's stdioStandard input/output — a transport where the client launches the server as a subprocess and communicates through stdin/stdout pipes. Ideal for local use. — the client launches your server as a subprocess and talks through stdin/stdout pipes (no network involved). For remote servers, there's HTTP+SSEHTTP with Server-Sent Events — a transport for remote MCP servers. The client connects over HTTP for requests, and the server pushes responses via SSE streams. — communication happens over the network using HTTP requests and server-sent event streams.

MCP Transport Comparison stdio (Local) Client App stdin → ← stdout Server (subprocess) ✓ Zero network latency ✓ No auth needed ✓ Simple setup △ Single client only △ Same machine only Best for: development, CLI tools, local IDE HTTP + SSE (Remote) Client App HTTP → ← SSE Server (remote) ✓ Multi-client support ✓ Cross-machine / cloud ✓ Scalable deployment △ Needs auth + TLS △ Network latency Best for: production, shared servers, teams
⚠️ Common Misconceptions

"MCP is just another API framework like REST or GraphQL" — No. REST and GraphQL are general-purpose web API standards. MCP is specifically a protocol for connecting AI models to tools and data. It defines not just how to call functions, but how to discover them, negotiate capabilities, and categorize them into Resources, Tools, and Prompts. You wouldn't build a web app with MCP, and you wouldn't connect an AI model to 20 tools with REST (at least, not without reinventing half of MCP).

"MCP servers are like chatbot plugins" — Not really. Chatbot plugins (like early ChatGPT plugins) were hosted web services that the AI called over the internet. MCP servers are typically local subprocesses — your computer launches them, and they communicate through stdin/stdout pipes with no network involved. This makes them faster, more secure (no auth needed for local processes), and easier to develop and debug.

"MCP replaces function calling" — No. MCP uses function calling under the hood. When Claude calls an MCP tool, the Messages API still uses tool_use content blocks internally. MCP adds a standardized discovery and transport layer on top of function calling, so tools from different servers can be composed without custom integration code.

"MCP servers need to be hosted on the internet" — Most MCP servers run as local subprocesses on your own machine. The stdio transport means the client just spawns your script and talks to it through pipes. Remote hosting (via HTTP+SSE) is available but is the exception, not the norm — especially during development.

Here's what that "universal plug" actually looks like in practice. When Claude calls a tool through MCP, the message on the wire is a JSON-RPC request — a simple JSON object with a method name and parameters:

// Client sends this to ANY MCP server: {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "read_file", "arguments": {"path": "app.py"}}} // Server responds: {"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "import os\n..."}]}}

That's the same message format whether the server reads files, queries a database, or calls a web API. The AI model doesn't care what's behind the server — it just speaks MCP.

❌ Before MCP: N × M 12 custom integrations Claude GPT Gemini Files Database Web API Git 3 × 4 = 12 integrations Each maintained separately ✓ With MCP: N + M 7 implementations Claude GPT Gemini MCP Protocol Files Database Web API Git 3 + 4 = 7 implementations Update one, all benefit
The Integration Problem: N×M → N+M

Animation disabled. Before MCP: each of 3 AI models needs 4 custom integrations = 12 connections. After MCP: 3 models + 4 tools each implement MCP once = 7 implementations.

Before MCP: Custom integrations
Claude
GPT
Gemini
Files
Database
Web API
Git
12 custom integrations (3 × 4)
Why It Matters

Let's put real numbers on this. Say your company uses 3 AI models (Claude, GPT, Gemini) and needs to connect them to 5 internal tools (Jira, PostgreSQL, S3, deploy pipeline, monitoring dashboard). Without MCP, that's 3 × 5 = 15 custom integrations to build and maintain. With MCP, each model implements the MCP client once (3) and each tool gets one MCP server (5) = 8 total implementations. But the real win is maintenance: when Jira's API changes, you update one MCP server and all three AI models get the fix instantly. Without MCP, you'd patch 3 separate integrations and hope you didn't miss one.

MCP Architecture: Client, Server, and Primitives

You now know why MCP exists — to replace the chaos of N×M custom integrations with a single universal standard. But how is it actually organized? What are the moving parts, and how do they talk to each other? Let's look under the hood at the architecture.

MCP Architecture MCP CLIENT Claude Desktop Claude Code Your AI App Hosts connections MCP Protocol JSON-RPC 2.0 stdio | HTTP+SSE MCP SERVER Your code that exposes capabilities Python / Node.js Resources Files, DB rows Read-only data Tools Actions, APIs Side effects Prompts Templates Workflows The client discovers capabilities via the protocol — no custom SDK required.
Everyday Analogy

Before: Imagine walking into a restaurant with no menu, no front-of-house staff, and no separation between the kitchen and the dining room — you'd have to walk into the kitchen yourself, find the ingredients, and hope you knew how to cook.

The pain: That's what building AI integrations without MCP feels like — your code has to know the internals of every data source and every API endpoint, with no standard way to discover what's available.

The mapping: MCP architecture works like a well-run restaurant. The client (diner) sits at the table and makes requests. The server (kitchen) handles all the work behind the scenes. And the menu lists three types of offerings: Resources (ingredients you can browse), Tools (dishes that require preparation), and Prompts (the chef's recommended pairings). The client never needs to know how the kitchen is organized — it just orders from the menu.

Here's what this "menu" looks like in practice. When the client connects, the server responds with its capabilities — a JSON object listing exactly what it offers:

// Server's capability response during handshake: {"capabilities": { "tools": {"listChanged": true}, // "We serve dishes" "resources": {"subscribe": true}, // "We have ingredients to browse" "prompts": {"listChanged": true} // "We have chef's recommendations" }, "serverInfo": {"name": "ucc-filing-server", "version": "1.0.0"}}

The client reads this response and knows exactly what's on the menu — no guessing, no trial and error.

Technical Definition

MCP defines three core primitivesThe fundamental building blocks of the MCP protocol — Resources (data), Tools (actions), and Prompts (templates) — that servers expose to clients.:

  • Resources — Read-only data the client can browse and retrieve (files, database records, API responses), identified by URIsUniform Resource Identifiers — strings that uniquely identify a resource, like file:///path/to/doc.txt or db://users/42..
  • Tools — Executable functions with JSON SchemaA vocabulary for annotating and validating JSON documents. MCP uses it to define the expected input parameters for each tool. input definitions that the model can invoke to perform actions.
  • Prompts — Reusable prompt templates the server provides, parameterized for specific workflows.

Communication follows a three-phase lifecycle. First, initialization: the client and server shake hands and tell each other what they support (which primitives, which protocol version). Second, operation: the client sends requests (like "call this tool" or "read this resource") and the server sends back results. Third, shutdown: either side can close the connection gracefully when done.

MCP Handshake & Protocol Flow

Animation disabled. The MCP handshake: (1) Client sends initialize with protocol version, (2) Server responds with capabilities, (3) Client sends initialized confirmation. Then tool calls and resource requests follow.

🤖
MCP Client
Claude Desktop
MCP Server
Your Server
Why It Matters

Without these three categories, a developer building a UCC filing server might expose 12 endpoints with no way for the AI to know which ones are safe to call speculatively (read-only queries) and which ones modify data (filing amendments). With MCP's primitives, you'd make debtor search and filing history into Resources (safe to browse freely), submit amendment and run risk scoring into Tools (actions with consequences), and lien analysis workflow into a Prompt (a reusable template). Now the AI client can browse Resources without asking permission, require confirmation before calling Tools, and offer Prompts as one-click workflows. This separation makes servers modular, testable, and composable — and it lets the client enforce appropriate safety guardrails for each category.

Building Your First MCP Server

Everyday Analogy

Before: If you wanted to share your cooking with the world before food trucks existed, you'd need a full brick-and-mortar restaurant — lease, build-out, staff, the whole thing. Most people with a great recipe never bothered because the overhead was too high.

The pain: That's the same barrier developers face when exposing a data source to AI — without MCP, you'd build a custom REST API, write authentication logic, handle serialization, and then build a different adapter for every AI model that wanted to use it.

The mapping: Building an MCP server is like setting up a food truck. You define your menu (tools), stock your ingredients (resources), open the service window, and any customer (MCP client) who speaks the protocol can order from you — no reservation needed, no custom adapter per customer.

Here's what the "food truck menu" looks like in code. When a client asks your server what tools are available, the server responds with a list like this:

// Response to tools/list request: {"tools": [ {"name": "read_file", "description": "Read a file's contents", "inputSchema": {"type": "object", "properties": {"path": {"type": "string", "description": "File path to read"}}, "required": ["path"]}}, {"name": "write_file", "description": "Write content to a file", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}} ]}

Each tool has a name, a description (so the AI knows when to use it), and an input schema (so the AI knows exactly what parameters to send). That's the entire "menu" — no custom SDK, no documentation site, just structured JSON.

Technical Definition

An MCP server is a small program whose entire job is to say: "Here are the things I can do — ask me anytime." You write regular functions — read a file, query a database, call an API — and then you register them with the MCP SDK. Once registered, any connected AI client can discover and call them automatically.

How you register depends on your language. In Python, you install the mcp package, instantiate FastMCP("name"), and use the @mcp.tool() decoratorA Python feature that wraps a function with additional behavior — here, it tells the MCP framework to expose this function as a callable tool. to mark each function as a tool. In Node.js, you install @modelcontextprotocol/sdk and call server.setRequestHandler() instead.

The server also defines input schemas — descriptions of what parameters each tool expects. These schemas tell the AI model exactly what data to send when calling a tool. Finally, you pick a transport. Use stdio for local servers — the client launches your server as a subprocess and talks through stdin/stdout pipes. Use HTTP+SSE for remote servers — communication happens over the network instead.

Building an MCP Server Step by Step

Animation disabled. Steps: (1) Import and create server, (2) Register a tool with decorator, (3) Implement the handler, (4) Start the server. Each step lights up a capability on the server.

from mcp.server.fastmcp import FastMCP
 
mcp = FastMCP("my-server")
 
# Register a tool
@mcp.tool()
def read_file(path: str):
"""Read a file's contents"""
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
return f"Error: {path} not found"
 
# Start server (stdio transport)
mcp.run()
Server Status
Offline
tools: read_file
resources: ready
prompts: ready
Why It Matters

Consider the alternative: to expose a single "read file" capability as a traditional REST API, you'd write ~150 lines covering an HTTP server, route handling, authentication, CORS headers, error serialization, and OpenAPI documentation. With MCP, the same capability is under 50 lines — define the function, add a decorator, done. And that one server instantly works with Claude Desktop, Claude Code, Cursor, Zed, and every other MCP-compatible client. A team at a healthcare startup reported connecting their patient-records tool to 4 different AI clients in a single afternoon — something that previously took them 3 weeks of per-client integration work.

Connecting Clients to MCP Servers

Everyday Analogy

Before: Remember setting up a printer in the early 2000s? You'd hunt for the right driver CD, install platform-specific software, restart your computer, and pray the system recognized the device. Every new printer meant repeating the whole ordeal.

The pain: Connecting AI models to external tools used to feel the same way — each tool needed its own SDK, authentication flow, and glue code, and upgrading one tool could break the whole integration.

The mapping: Connecting Claude to an MCP server is like pairing a modern Bluetooth device: you tell the client where to find the server (a single config entry), they shake hands automatically (capability negotiation), and from that point on the client can use everything the server offers — no driver installs, no custom glue code.

Here's what that "pairing" looks like in practice — a single config entry in a JSON file is all it takes to connect Claude to a new server:

// One entry in claude_desktop_config.json: {"my-filesystem": {"command": "python", "args": ["filesystem_server.py"], "env": {"ALLOWED_PATHS": "/home/user/projects"}}}

That's it. Claude Desktop reads this, launches your script, handshakes, and your tools appear in Claude's menu. Compare that to the old way: write an HTTP server, add auth, build a client SDK, document the API, and repeat for every AI model.

Technical Definition

To connect Claude Desktop to your MCP servers, you create a configuration file called claude_desktop_config.json. Think of it as a contact list: for each server, you write down the command to launch it (like python or node), the arguments it needs (the path to your server script), and any environment variables (like database credentials or allowed file paths).

Here's what happens under the hood when Claude Desktop starts. First, it reads the config file and finds each server entry. Second, for each server, it spawns a subprocessA separate program started and managed by a parent program. Here, Claude Desktop starts your MCP server and talks to it through pipes. — literally running python your_server.py as a child process. Third, it performs the MCP handshake with each subprocess (sending initialize, receiving capabilities, confirming with initialized). Finally, it merges all discovered tools, resources, and prompts from every server into a single unified menu. You can connect as many servers as you want simultaneously — Claude sees them all as one big toolbox.

This is fundamentally different from how you connected tools in M06. In function calling, you defined tools inline in your API request — the tools lived in your code. With MCP, each tool lives in its own server process, discovered at runtime through the protocol.

This unlocks a powerful workflow: someone else can write an MCP server (say, the Slack team publishes one), and you just add it to your config — no code changes needed. Want to build your own client programmatically instead of using Claude Desktop? The MCP SDK provides a Client class that handles connection, capability discovery, and proxying tool calls to the server — all in a few lines of code.

Multi-Server Orchestration

Animation disabled. Claude Desktop in the center connects to three MCP servers (filesystem, database, web API) and orchestrates tool calls across them in a single workflow.

🤖
Claude Desktop
📁
Filesystem
read_file, write_file
🗃
Database
query, list_tables
🌐
Web API
fetch_url, search

Here's the configuration file that wires Claude Desktop to multiple MCP servers:

{
  "mcpServers": {
    "filesystem": {
      "command": "python",
      "args": ["-m", "mcp_filesystem_server"],
      "env": {
        "ALLOWED_PATHS": "/home/user/projects"
      }
    },
    "database": {
      "command": "node",
      "args": ["mcp-db-server/index.js"],
      "env": {
        "DB_PATH": "./data/app.db"
      }
    },
    "web-api": {
      "command": "python",
      "args": ["-m", "mcp_web_server"],
      "env": {
        "API_BASE_URL": "https://api.example.com"
      }
    }
  }
}
Security Note

MCP servers run with the same permissions as the process that launches them. Always use environment variables for sensitive values (never hardcode API keys), and restrict file paths with ALLOWED_PATHS to prevent unauthorized access.

🎓 Cert Tip — Domain 2.4

Never hardcode API keys in .mcp.json — these files get committed to git. Use ${ENV_VAR} environment variable expansion. Also know the config hierarchy: .mcp.json (project) vs ~/.claude.json (user).

Why It Matters

The real power of MCP emerges when you connect multiple servers. Imagine asking Claude: "Find all UCC filings for Acme Corp, check their risk scores in the database, and draft a summary email." That single prompt touches 3 different MCP servers — filesystem (read filing documents), database (query risk scores), and web API (send email) — and Claude orchestrates all three seamlessly in one conversation turn. Without multi-server support, you'd need to manually copy-paste data between separate tools or write brittle glue code. This is the same orchestration pattern you learned in M06: Multi-Tool Orchestration, but now each tool lives in its own isolated, reusable server.

Resources vs. Tools vs. Prompts

Everyday Analogy

Before: Walk into a large library with no signs, no catalog system, and no staff — you'd wander the stacks guessing where things are, with no way to tell what's reference-only vs. what you can check out vs. what requires a librarian's help.

The pain: Without clear categories, AI models face the same confusion with your data. They can't tell what they should just read, what they're allowed to modify, and what workflow to follow. Everything looks like an undifferentiated blob of "stuff I can maybe call."

The mapping: MCP solves this by giving you three distinct primitives. Resources are like the reference section — browse and read, no side effects. Tools are like the services desk — request an action and get a result back. Prompts are like the librarian's recommended reading lists — curated starting points that guide the model through common tasks.

Technical Definition
  • Resources — Use when the client should browse or read data without side effects. Identified by URIs, they can be listed and read. Examples: file contents, database records, config values.
  • Tools — Use when the model needs to trigger an action with side effects or computation. Tools have input schemas and return results. Examples: running a query, sending a message, transforming data.
  • Prompts — Use when you want reusable prompt templates that guide the model. They accept parameters and return pre-structured message arrays. Examples: code review template, analysis framework.

Of the three primitives, Prompts are the one that surprises most newcomers. Resources and Tools map to familiar concepts (reading data and calling functions), but what exactly is a "server-side prompt template"? Think of it this way: instead of the user typing out a detailed instruction every time they want Claude to analyze a file, the server provides a pre-written prompt with placeholders. The user just fills in the parameter (a file path, a table name) and gets a consistent, structured analysis every time.

Under the hood, a Prompt is a function on the server that takes parameters and returns an array of messages — the same format you'd pass to the Messages API. When the client requests a prompt, the server fills in the template and hands back ready-to-use messages. The client can then inject those messages directly into the conversation. This is powerful because it lets the server author — the domain expert — control how the AI approaches a task, not just what data it has access to.

How is this different from just writing a system prompt? Two key differences. First, Prompts are discoverable — the client can list all available prompts and show them to the user as one-click workflows, like a menu of expert-curated analysis templates. Second, Prompts are parameterized — they accept arguments (like a file path or table name) so one template serves many use cases. A system prompt is static; a Prompt is a reusable function that generates context-specific instructions.

Here's what each primitive looks like "on the wire" — the actual JSON-RPC messages exchanged between client and server. Notice how the method name tells you exactly which primitive you're using:

// RESOURCE — read-only, no side effects → {"method": "resources/read", "params": {"uri": "file://src/app.py"}} ← {"contents": [{"text": "import os\n...", "mimeType": "text/plain"}]} // TOOL — action with side effects, requires model invocation → {"method": "tools/call", "params": {"name": "run_linter", "arguments": {"path": "src/app.py"}}} ← {"content": [{"type": "text", "text": "{\"issues\": 3, \"warnings\": 7}"}]} // PROMPT — reusable template, returns pre-structured messages → {"method": "prompts/get", "params": {"name": "code_review", "arguments": {"path": "src/app.py"}}} ← {"messages": [{"role": "user", "content": {"type": "text", "text": "Analyze src/app.py..."}}]}

The key distinction: Resources return data (raw content), Tools return results (from executing an action), and Prompts return messages (structured prompts ready to feed into a conversation). This is how one server can offer all three types of capability through a single protocol.

⚠️ Common Misconceptions — Primitives

"Resources and Tools are basically the same thing" — They look similar but have a critical difference: side effects. A Resource is read-only — the client can browse it freely without asking the user for permission. A Tool can change state (write a file, send an email, run a query), so the client typically asks the user to confirm before calling it. Misclassifying a read-only operation as a Tool means unnecessary confirmation prompts on every call.

"I should make everything a Tool since Tools are more powerful" — This is a common over-engineering mistake. Tools cost more — each call requires a round-trip with ~200 extra tokens of tool-use framing. If your operation just returns data with no side effects (like reading a schema or listing files), make it a Resource. Your token budget and your users will thank you.

"Prompts are just fancy system prompts" — System prompts are static text you set once at the start of a conversation. MCP Prompts are parameterized templates that live on the server, are discoverable by the client, and generate context-specific messages on demand. The server author controls the template; the user just fills in the blanks.

"Every server needs all three primitives" — Not at all. A database server might only expose Tools (queries) and Resources (schema). A documentation server might only expose Resources. Use only the primitives that fit your use case — the protocol doesn't require all three.

Three Primitives in a Code Review Workflow

Animation disabled. Three lanes: Resources (read source files), Tools (run linter, run tests), Prompts (code review template). All three work together in a single workflow.

Resources (Read)
file://src/app.py
file://src/utils.py
file://tests/test_app.py
Tools (Execute)
run_linter(src/app.py)
run_tests(tests/)
format_code(src/app.py)
Prompts (Template)
code_review(path, focus)
→ structured analysis
→ recommendations
Workflow: Read files → Run checks → Apply review template
Why It Matters

Choosing the right primitive is a design decision that directly affects cost and safety. If you make a read-only database query into a Tool instead of a Resource, the AI model must explicitly "call" it every time — which means more round-trips, more token usage (~200 extra tokens per call for the tool-use framing), and unnecessary confirmation prompts for the user. In a B2B ecommerce dashboard with 50 order lookups per session, that's 10,000 wasted tokens per conversation just from misclassifying reads as actions. The rule of thumb: if it has no side effects, make it a Resource. If it changes state, make it a Tool. If it's a reusable workflow template, make it a Prompt.

The Production MCP Ecosystem

The toy filesystem server is great for learning the protocol, but production teams reach for a small set of canonical MCP servers that connect Claude to the systems where real engineering work happens: source control, the database, the messaging system, and the ticket queue. Once you understand which servers to compose — and how to scope each one with least-privilege — you stop copy-pasting between tools and Claude becomes a first-class participant in your engineering workflow.

Common MCP Servers in Production Topology
Claude Code main agent .claude/settings.json GitHub MCP PRs, issues, commits scoped read-write Postgres MCP SELECT only read-only DSN Slack MCP channel context, alerts scoped channel set Jira MCP tickets, sprints scoped project Internal API MCP your services requires custom auth Postgres MCP (write) dev DB only — never prod guarded by hook explicit toggle only

Least-Privilege by Default

The single most important rule when wiring up MCP: read-only by default. For nine out of ten tasks, Claude needs to read your database, not write to it. Set up two MCP server entries — one read-only for exploration and debugging, one read-write gated behind explicit permission — and only enable the write server when you actually need to mutate state.

{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
    },
    "postgres-readonly": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres"],
      "env": {
        "POSTGRES_CONNECTION_STRING": "${PG_READONLY_DSN}"
      }
    },
    "slack": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-slack"],
      "env": {
        "SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}",
        "SLACK_CHANNELS": "eng-alerts,deploys"
      }
    },
    "jira": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-jira"],
      "env": {
        "JIRA_HOST": "${JIRA_HOST}",
        "JIRA_TOKEN": "${JIRA_TOKEN}",
        "JIRA_PROJECT_KEYS": "ENG,INFRA"
      }
    }
  }
}

Notice three discipline patterns: (1) all secrets come from ${ENV_VAR} — never inline. (2) postgres-readonly uses a read-only connection string — the database role itself is restricted. (3) Slack and Jira are scoped to specific channels and project keys — the MCP server only sees what you explicitly allowlist. The write-capable Postgres server is intentionally absent — add it only when an agent genuinely needs to mutate data, and even then point it at a dev DB.

Skill or MCP — Which to Build?

A common architectural question: do I write a skill or build an MCP server? They look similar from the outside — both extend Claude's capabilities — but they solve different problems.

Skill vs MCP — Decision Matrix
If you want to give Claude... Build a... Why
A workflow, pattern, or domain knowledge ("here's how we deploy to k8s")SkillMarkdown is auditable; the team can read what Claude is being told
Live data or actions ("query the current state of prod orders")MCP serverOnly an executable can fetch/write live state; a skill cannot
A reusable command Claude should expose ("/run-migration")Slash command + skillSlash command is the entry point; skill carries the procedure
Both knowledge AND live data ("resolve a deploy incident")Skill that calls an MCP serverThe skill describes the procedure; MCP provides the live actions

Heuristic: prefer skills when in doubt — you can read and audit a skill, while an MCP server is a black box. Reach for MCP only when you need live data or remote actions that a skill alone cannot deliver.

Production Security Checklist

1. Every MCP server runs with the privilege of its credentials — treat MCP env vars like deploy secrets. 2. Default DB connection strings to read-only roles; require explicit opt-in for write. 3. Allowlist channels/projects/orgs at the MCP env level, not in prompts. 4. Audit every MCP server's source before installing — stdio servers run with full local privilege. 5. Pair sensitive MCP servers with a PreToolUse hook that double-checks tool name and parameters before execution. 6. Never commit .mcp.json with literal tokens — use ${ENV_VAR} expansion only.

You now know the canonical MCP servers, how to scope them with least privilege, and when to reach for a skill vs MCP. Time to build one yourself — the code walkthrough next implements a filesystem server end-to-end.

Code Walkthrough

From concepts to code: You now understand what MCP is (a universal protocol), how it's structured (client/server with three primitives), and why it matters (N+M instead of N×M). The next question is: how do you actually build one? Before writing code, think about what data your project has that an AI would benefit from. For the UCC pipeline, that's filing searches, risk queries, and amendment history. For a B2B ecommerce system, it might be order lookups, shipment tracking, and SLA dashboards. The MCP server you build bridges your data and the AI model. Let's start with something universal — a filesystem server — then graduate to a database server.

Let's build a complete MCP server that exposes file operations as tools, directory listings as resources, and a file analysis prompt template. This server works with Claude Desktop out of the box.

Server 1: Filesystem MCP Server

This server has four logical chunks. Here's what each one does and why:

Chunk 1 — Imports & path security (lines 1-12): Let's start with the most important design decision in this server: path restriction. The ALLOWED_ROOT variable and is_allowed() function work together to ensure the AI model can only touch files inside a specific directory. Without this, an AI model could read any file on your machine — including /etc/passwd or ~/.ssh/id_rsa. Here's the subtle part: the function uses Path.resolve() to follow symlinks before checking the path. Without this, a crafty path like ../../etc/passwd would bypass a naive string check entirely.

Chunk 2 — Tool definitions (lines 14-45): Now we define three tools — read, write, and list — each decorated with @mcp.tool(). The decorator is the key line here. It tells MCP: "expose this Python function as a callable tool that AI models can discover and invoke." Without it, the function exists in your code but is completely invisible to Claude. The interesting part is that the decorator also auto-generates the tool's JSON Schema from your type hints. So when you write path: str, MCP automatically tells the client "this parameter expects a string" — no manual schema writing needed.

Chunk 3 — Resource & prompt (lines 47-67): Here's where the choice of primitive really matters. The file tree is registered as a Resource (not a Tool) because browsing a directory has no side effects — the client can read it freely without asking the user for confirmation. If we had made this a Tool instead, the user would get a confirmation prompt every time Claude wanted to look at the file tree, which would be annoying for something completely harmless. The prompt template serves a different purpose: it standardizes how Claude analyzes files, so you get consistent, structured output every time instead of freeform responses.

Chunk 4 — Server startup (lines 69-71): The last piece is wiring everything up to a transport. Calling mcp.run() hands off to the FastMCP runtime, which spins up a stdio loop — reading JSON-RPC messages from stdin and writing responses to stdout. When Claude Desktop launches this script as a subprocess, this is the code that keeps the conversation going between client and server.

# filesystem_server.py
# pip install "mcp[cli]>=1.0.0"
import os
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("filesystem-server")

# Restrict to allowed paths for security
ALLOWED_ROOT = os.environ.get("ALLOWED_PATHS", os.getcwd())

def is_allowed(path: str) -> bool:
    """Check if path is within allowed directory."""
    resolved = Path(path).resolve()
    return str(resolved).startswith(str(Path(ALLOWED_ROOT).resolve()))

# --- TOOL: Read a file ---
@mcp.tool()
def read_file(path: str) -> str:
    """Read the contents of a file at the given path."""
    try:
        if not is_allowed(path):
            return json.dumps({"error": "Path outside allowed directory"})
        with open(path, "r", encoding="utf-8") as f:
            content = f.read()
        return json.dumps({"path": path, "content": content})
    except FileNotFoundError:
        return json.dumps({"error": f"File not found: {path}"})
    except PermissionError:
        return json.dumps({"error": f"Permission denied: {path}"})
    except Exception as e:
        return json.dumps({"error": f"Unexpected error: {str(e)}"})

# --- TOOL: Write a file ---
@mcp.tool()
def write_file(path: str, content: str) -> str:
    """Write content to a file at the given path."""
    try:
        if not is_allowed(path):
            return json.dumps({"error": "Path outside allowed directory"})
        os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
        return json.dumps({"status": "ok", "path": path, "bytes": len(content)})
    except PermissionError:
        return json.dumps({"error": f"Permission denied: {path}"})
    except Exception as e:
        return json.dumps({"error": f"Unexpected error: {str(e)}"})

# --- TOOL: List directory ---
@mcp.tool()
def list_directory(path: str) -> str:
    """List files and subdirectories at the given path."""
    try:
        if not is_allowed(path):
            return json.dumps({"error": "Path outside allowed directory"})
        entries = []
        for entry in os.scandir(path):
            entries.append({
                "name": entry.name,
                "type": "dir" if entry.is_dir() else "file",
                "size": entry.stat().st_size if entry.is_file() else None
            })
        return json.dumps({"path": path, "entries": entries})
    except FileNotFoundError:
        return json.dumps({"error": f"Directory not found: {path}"})
    except Exception as e:
        return json.dumps({"error": f"Unexpected error: {str(e)}"})

# --- RESOURCE: File tree of working directory ---
@mcp.resource("file-tree://cwd")
def get_file_tree() -> str:
    """Browse the current working directory file tree."""
    tree = []
    for root, dirs, files in os.walk(ALLOWED_ROOT):
        depth = root.replace(ALLOWED_ROOT, "").count(os.sep)
        if depth > 3:  # Limit depth to prevent huge outputs
            continue
        indent = "  " * depth
        tree.append(f"{indent}{os.path.basename(root)}/")
        for f in files[:20]:  # Limit files per directory
            tree.append(f"{indent}  {f}")
    return "\n".join(tree)

# --- PROMPT: File summary ---
@mcp.prompt()
def file_summary(path: str) -> str:
    """Generate a structured file analysis prompt."""
    return (
        f"Analyze the file at '{path}'. Provide:\n"
        "1. A one-line summary of what the file does\n"
        "2. Key functions/classes and their purposes\n"
        "3. Dependencies and imports\n"
        "4. Potential issues or improvements\n"
        "5. A complexity rating (Low/Medium/High)"
    )

# --- Start server (stdio transport) ---
if __name__ == "__main__":
    mcp.run()
// filesystem_server.js
// npm install @modelcontextprotocol/sdk@^1.0.0
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import fs from "fs/promises";
import path from "path";

const ALLOWED_ROOT = process.env.ALLOWED_PATHS || process.cwd();

function isAllowed(filePath) {
  const resolved = path.resolve(filePath);
  return resolved.startsWith(path.resolve(ALLOWED_ROOT));
}

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

// --- TOOL: Read a file ---
server.setRequestHandler("tools/call", async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "read_file") {
    try {
      if (!isAllowed(args.path)) {
        return { content: [{ type: "text", text: JSON.stringify({ error: "Path outside allowed directory" }) }] };
      }
      const content = await fs.readFile(args.path, "utf-8");
      return { content: [{ type: "text", text: JSON.stringify({ path: args.path, content }) }] };
    } catch (err) {
      const msg = err.code === "ENOENT" ? `File not found: ${args.path}`
                : err.code === "EACCES" ? `Permission denied: ${args.path}`
                : `Unexpected error: ${err.message}`;
      return { content: [{ type: "text", text: JSON.stringify({ error: msg }) }] };
    }
  }

  if (name === "write_file") {
    try {
      if (!isAllowed(args.path)) {
        return { content: [{ type: "text", text: JSON.stringify({ error: "Path outside allowed directory" }) }] };
      }
      await fs.mkdir(path.dirname(args.path), { recursive: true });
      await fs.writeFile(args.path, args.content, "utf-8");
      return { content: [{ type: "text", text: JSON.stringify({ status: "ok", path: args.path, bytes: args.content.length }) }] };
    } catch (err) {
      return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
    }
  }

  if (name === "list_directory") {
    try {
      if (!isAllowed(args.path)) {
        return { content: [{ type: "text", text: JSON.stringify({ error: "Path outside allowed directory" }) }] };
      }
      const entries = await fs.readdir(args.path, { withFileTypes: true });
      const result = await Promise.all(entries.map(async (e) => {
        const stat = e.isFile() ? await fs.stat(path.join(args.path, e.name)) : null;
        return { name: e.name, type: e.isDirectory() ? "dir" : "file", size: stat?.size ?? null };
      }));
      return { content: [{ type: "text", text: JSON.stringify({ path: args.path, entries: result }) }] };
    } catch (err) {
      return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
    }
  }

  return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }] };
});

// --- Tool listing ---
server.setRequestHandler("tools/list", async () => ({
  tools: [
    { name: "read_file", description: "Read a file's contents", inputSchema: { type: "object", properties: { path: { type: "string", description: "File path to read" } }, required: ["path"] } },
    { name: "write_file", description: "Write content to a file", inputSchema: { type: "object", properties: { path: { type: "string", description: "File path to write" }, content: { type: "string", description: "Content to write" } }, required: ["path", "content"] } },
    { name: "list_directory", description: "List directory contents", inputSchema: { type: "object", properties: { path: { type: "string", description: "Directory path" } }, required: ["path"] } }
  ]
}));

// --- RESOURCE: File tree ---
server.setRequestHandler("resources/list", async () => ({
  resources: [{ uri: "file-tree://cwd", name: "Working Directory Tree", description: "Browse the file tree", mimeType: "text/plain" }]
}));
server.setRequestHandler("resources/read", async (request) => {
  if (request.params.uri === "file-tree://cwd") {
    const tree = [];
    async function walk(dir, depth) {
      if (depth > 3) return;
      const entries = await fs.readdir(dir, { withFileTypes: true });
      for (const e of entries.slice(0, 20)) {
        const indent = "  ".repeat(depth);
        tree.push(`${indent}${e.name}${e.isDirectory() ? "/" : ""}`);
        if (e.isDirectory()) await walk(path.join(dir, e.name), depth + 1);
      }
    }
    await walk(ALLOWED_ROOT, 0);
    return { contents: [{ uri: "file-tree://cwd", mimeType: "text/plain", text: tree.join("\n") }] };
  }
  return { contents: [] };
});

// --- PROMPT: File summary ---
server.setRequestHandler("prompts/list", async () => ({
  prompts: [{ name: "file_summary", description: "Structured file analysis prompt", arguments: [{ name: "path", description: "File path to analyze", required: true }] }]
}));
server.setRequestHandler("prompts/get", async (request) => {
  if (request.params.name === "file_summary") {
    return {
      messages: [{
        role: "user",
        content: { type: "text", text: `Analyze the file at '${request.params.arguments.path}'. Provide:\n1. A one-line summary\n2. Key functions/classes\n3. Dependencies\n4. Potential issues\n5. Complexity rating (Low/Medium/High)` }
      }]
    };
  }
});

// --- Start ---
const transport = new StdioServerTransport();
await server.connect(transport);
Expected Output (when connected via Claude Desktop)
Connected to MCP server: filesystem-server Discovered 3 tools: read_file, write_file, list_directory Discovered 1 resource: file-tree://cwd Discovered 1 prompt: file_summary

What Just Happened? You created a filesystem MCP server with 3 tools (read, write, list), 1 resource (directory tree), and 1 prompt (file analysis template). When Claude Desktop connects, it launches your script as a subprocess, runs the MCP handshake, discovers all 5 capabilities, and adds them to Claude's available actions. From this point, Claude can read files, write files, browse the directory tree, and use the analysis template — all through the same universal protocol, with path security enforced on every operation.

Thinking ahead: A filesystem server is a great starting point, but most real-world AI workflows also need structured data. What if Claude could query your database directly — listing tables, running SQL, and exploring schemas? That's exactly what our next server does.

Server 2: SQLite Database MCP Server

Chunk 1 — Connection & safety (lines 1-13): The very first thing this server does is set up a safety net. The PRAGMA query_only = ON line tells SQLite: "reject any statement that tries to modify data." This is your primary defense — even if the AI model generates a DROP TABLE statement (and it might!), SQLite will refuse to execute it. Defense in depth matters when an AI is writing SQL. One important caveat: PRAGMA query_only is SQLite-specific. If you're building a similar server for PostgreSQL or MySQL, you'd create a read-only database user instead.

Chunk 2 — Query & schema tools (lines 15-52): Now here's where things get interesting. We need Claude to explore a database it's never seen before — so what tools does it need? Think about how you explore an unfamiliar database. First, you'd ask "what tables exist?" Then you'd pick a table and ask "what columns does it have?" And finally, you'd run a query to see actual data. That's exactly the three tools here: list_tables, describe_table, and query. They form a progressive discovery workflow. One subtle but important detail: the query tool caps results at 100 rows. Why? Because without that limit, Claude might innocently run SELECT * FROM orders on a million-row table and dump the entire result into the conversation window — blowing through your token budget in a single call. The 100-row cap is a safety valve that keeps costs predictable.

Chunk 3 — Schema resource & prompt (lines 54-72): Here's a clever optimization. The db://schema resource provides the entire database structure as browsable text. Why not just use describe_table for each table? Because if your database has 10 tables, that's 10 separate tool calls — each one costing a round-trip and ~200 tokens of tool-use framing. The resource gives Claude the same information in a single read, which is both cheaper and faster. The explore_data prompt template takes this further by standardizing how Claude analyzes any table, so you get consistent, structured output every time instead of freeform responses that vary with each conversation.

# database_server.py
# pip install "mcp[cli]>=1.0.0"
import os
import json
import sqlite3
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("database-server")
DB_PATH = os.environ.get("DB_PATH", "./data/app.db")

def get_conn():
    """Create a database connection with safety settings."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA query_only = ON")  # Read-only by default
    return conn

# --- TOOL: Run a SQL query ---
@mcp.tool()
def query(sql: str) -> str:
    """Execute a read-only SQL query and return results."""
    try:
        conn = get_conn()
        cursor = conn.execute(sql)
        columns = [desc[0] for desc in cursor.description] if cursor.description else []
        rows = [dict(row) for row in cursor.fetchall()[:100]]  # Limit rows
        conn.close()
        return json.dumps({"columns": columns, "rows": rows, "count": len(rows)})
    except sqlite3.OperationalError as e:
        return json.dumps({"error": f"SQL error: {str(e)}"})
    except Exception as e:
        return json.dumps({"error": f"Unexpected error: {str(e)}"})

# --- TOOL: List tables ---
@mcp.tool()
def list_tables() -> str:
    """List all tables in the database."""
    try:
        conn = get_conn()
        cursor = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
        )
        tables = [row["name"] for row in cursor.fetchall()]
        conn.close()
        return json.dumps({"tables": tables})
    except Exception as e:
        return json.dumps({"error": str(e)})

# --- TOOL: Describe table schema ---
@mcp.tool()
def describe_table(table_name: str) -> str:
    """Get the schema of a specific table."""
    try:
        conn = get_conn()
        cursor = conn.execute(f"PRAGMA table_info({table_name})")
        columns = [
            {"name": row["name"], "type": row["type"], "nullable": not row["notnull"]}
            for row in cursor.fetchall()
        ]
        conn.close()
        if not columns:
            return json.dumps({"error": f"Table not found: {table_name}"})
        return json.dumps({"table": table_name, "columns": columns})
    except Exception as e:
        return json.dumps({"error": str(e)})

# --- RESOURCE: Database schema overview ---
@mcp.resource("db://schema")
def get_schema() -> str:
    """Browse the full database schema."""
    try:
        conn = get_conn()
        tables = conn.execute(
            "SELECT sql FROM sqlite_master WHERE type='table'"
        ).fetchall()
        conn.close()
        return "\n\n".join(row["sql"] for row in tables if row["sql"])
    except Exception as e:
        return f"Error: {str(e)}"

# --- PROMPT: Data exploration ---
@mcp.prompt()
def explore_data(table_name: str) -> str:
    """Prompt template for exploring a database table."""
    return (
        f"Explore the '{table_name}' table. Provide:\n"
        "1. Row count and column summary\n"
        "2. Sample of 5 representative rows\n"
        "3. Distribution of key columns\n"
        "4. Any data quality issues (nulls, duplicates)\n"
        "5. Suggested queries for deeper analysis"
    )

# --- Start server (stdio transport) ---
if __name__ == "__main__":
    mcp.run()
// database_server.js
// npm install @modelcontextprotocol/sdk@^1.0.0 better-sqlite3@^11.0.0
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import Database from "better-sqlite3";

const DB_PATH = process.env.DB_PATH || "./data/app.db";
const db = new Database(DB_PATH, { readonly: true });

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

server.setRequestHandler("tools/call", async (request) => {
  const { name, arguments: args } = request.params;

  if (name === "query") {
    try {
      const stmt = db.prepare(args.sql);
      const rows = stmt.all().slice(0, 100);
      const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
      return { content: [{ type: "text", text: JSON.stringify({ columns, rows, count: rows.length }) }] };
    } catch (err) {
      return { content: [{ type: "text", text: JSON.stringify({ error: `SQL error: ${err.message}` }) }] };
    }
  }

  if (name === "list_tables") {
    try {
      const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
      return { content: [{ type: "text", text: JSON.stringify({ tables: tables.map(t => t.name) }) }] };
    } catch (err) {
      return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
    }
  }

  if (name === "describe_table") {
    try {
      const cols = db.prepare(`PRAGMA table_info(${args.table_name})`).all();
      if (!cols.length) return { content: [{ type: "text", text: JSON.stringify({ error: `Table not found: ${args.table_name}` }) }] };
      const columns = cols.map(c => ({ name: c.name, type: c.type, nullable: !c.notnull }));
      return { content: [{ type: "text", text: JSON.stringify({ table: args.table_name, columns }) }] };
    } catch (err) {
      return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
    }
  }

  return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }] };
});

server.setRequestHandler("tools/list", async () => ({
  tools: [
    { name: "query", description: "Execute a read-only SQL query", inputSchema: { type: "object", properties: { sql: { type: "string", description: "SQL query" } }, required: ["sql"] } },
    { name: "list_tables", description: "List all tables", inputSchema: { type: "object", properties: {} } },
    { name: "describe_table", description: "Get table schema", inputSchema: { type: "object", properties: { table_name: { type: "string", description: "Table name" } }, required: ["table_name"] } }
  ]
}));

server.setRequestHandler("resources/list", async () => ({
  resources: [{ uri: "db://schema", name: "Database Schema", description: "Full database schema", mimeType: "text/plain" }]
}));
server.setRequestHandler("resources/read", async (request) => {
  if (request.params.uri === "db://schema") {
    const tables = db.prepare("SELECT sql FROM sqlite_master WHERE type='table'").all();
    return { contents: [{ uri: "db://schema", mimeType: "text/plain", text: tables.map(t => t.sql).filter(Boolean).join("\n\n") }] };
  }
  return { contents: [] };
});

server.setRequestHandler("prompts/list", async () => ({
  prompts: [{ name: "explore_data", description: "Explore a database table", arguments: [{ name: "table_name", required: true }] }]
}));
server.setRequestHandler("prompts/get", async (request) => {
  if (request.params.name === "explore_data") {
    return {
      messages: [{
        role: "user",
        content: { type: "text", text: `Explore the '${request.params.arguments.table_name}' table. Provide:\n1. Row count and column summary\n2. Sample of 5 rows\n3. Distribution of key columns\n4. Data quality issues\n5. Suggested queries` }
      }]
    };
  }
});

const transport = new StdioServerTransport();
await server.connect(transport);

What Just Happened? You built a database MCP server with 3 tools (query, list tables, describe table), 1 resource (full schema), and 1 prompt template (data exploration). The read-only PRAGMA ensures the AI can explore but never modify your data. Combined with the filesystem server from earlier, Claude can now read files and query databases in a single conversation — each capability living in its own isolated, reusable server.

Cost Tip

Resources are read on-demand and don't count toward tool call costs. If your server exposes large datasets, prefer Resources (let the model browse) over Tools (which consume a tool-call round-trip for every invocation). For the database server, the db://schema resource lets Claude read the full schema once instead of calling describe_table for each table.

Hands-On Exercise

What You'll Build

A filesystem MCP server with 3 tools (read, write, list), 1 resource (directory tree), and 1 prompt template (file analysis) — then connect it to Claude Desktop and verify all capabilities are discovered.

Time Estimate: 30–45 minutes

Prerequisites: Python 3.10+ installed, Claude Desktop installed and signed in, a terminal/command prompt

Files You'll Create: filesystem_server.py, claude_desktop_config.json (edit)

Environment Setup

mkdir my-mcp-server && cd my-mcp-server
python -m venv venv && source venv/bin/activate   # Windows: venv\Scripts\activate
pip install "mcp[cli]>=1.0.0"
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk@^1.0.0

Step 1: Create the Filesystem MCP Server

This step creates the complete MCP server with path security, three tools (read, write, list), a directory tree resource, and a file analysis prompt template. The server uses stdio transport, so Claude Desktop will launch it as a subprocess.

Create a new file called filesystem_server.py and add the following:

# filesystem_server.py — MCP server with tools, resources, and prompts
# pip install "mcp[cli]>=1.0.0"
import os
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("filesystem-server")

# Restrict to allowed paths for security
ALLOWED_ROOT = os.environ.get("ALLOWED_PATHS", os.getcwd())

def is_allowed(path: str) -> bool:
    """Check if path is within allowed directory (follows symlinks)."""
    resolved = Path(path).resolve()
    return str(resolved).startswith(str(Path(ALLOWED_ROOT).resolve()))

# ── TOOL: Read a file ────────────────────────────────────────
@mcp.tool()
def read_file(path: str) -> str:
    """Read the contents of a file at the given path."""
    try:
        if not is_allowed(path):
            return json.dumps({"error": "Path outside allowed directory"})
        with open(path, "r", encoding="utf-8") as f:
            content = f.read()
        return json.dumps({"path": path, "content": content})
    except FileNotFoundError:
        return json.dumps({"error": f"File not found: {path}"})
    except PermissionError:
        return json.dumps({"error": f"Permission denied: {path}"})
    except Exception as e:
        return json.dumps({"error": f"Unexpected error: {str(e)}"})

# ── TOOL: Write a file ───────────────────────────────────────
@mcp.tool()
def write_file(path: str, content: str) -> str:
    """Write content to a file at the given path."""
    try:
        if not is_allowed(path):
            return json.dumps({"error": "Path outside allowed directory"})
        os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            f.write(content)
        return json.dumps({"status": "ok", "path": path, "bytes": len(content)})
    except PermissionError:
        return json.dumps({"error": f"Permission denied: {path}"})
    except Exception as e:
        return json.dumps({"error": f"Unexpected error: {str(e)}"})

# ── TOOL: List directory ─────────────────────────────────────
@mcp.tool()
def list_directory(path: str) -> str:
    """List files and subdirectories at the given path."""
    try:
        if not is_allowed(path):
            return json.dumps({"error": "Path outside allowed directory"})
        entries = []
        for entry in os.scandir(path):
            entries.append({
                "name": entry.name,
                "type": "dir" if entry.is_dir() else "file",
                "size": entry.stat().st_size if entry.is_file() else None
            })
        return json.dumps({"path": path, "entries": entries})
    except FileNotFoundError:
        return json.dumps({"error": f"Directory not found: {path}"})
    except Exception as e:
        return json.dumps({"error": f"Unexpected error: {str(e)}"})

# ── RESOURCE: File tree of working directory ──────────────────
@mcp.resource("file-tree://cwd")
def get_file_tree() -> str:
    """Browse the current working directory file tree."""
    tree = []
    for root, dirs, files in os.walk(ALLOWED_ROOT):
        depth = root.replace(ALLOWED_ROOT, "").count(os.sep)
        if depth > 3:
            continue
        indent = "  " * depth
        tree.append(f"{indent}{os.path.basename(root)}/")
        for f in files[:20]:
            tree.append(f"{indent}  {f}")
    return "\n".join(tree)

# ── PROMPT: File analysis template ────────────────────────────
@mcp.prompt()
def file_summary(path: str) -> str:
    """Generate a structured file analysis prompt."""
    return (
        f"Analyze the file at '{path}'. Provide:\n"
        "1. A one-line summary of what the file does\n"
        "2. Key functions/classes and their purposes\n"
        "3. Dependencies and imports\n"
        "4. Potential issues or improvements\n"
        "5. A complexity rating (Low/Medium/High)"
    )

# ── Start server (stdio transport) ───────────────────────────
if __name__ == "__main__":
    mcp.run()

Run Command — test that the server starts without errors:

Run these commands in your terminal
python -c "from mcp.server.fastmcp import FastMCP; print('MCP SDK installed')" python -c "import filesystem_server; print('Server module loads without errors')"
Expected Output
MCP SDK installed Server module loads without errors
✅ Checkpoint

If both checks pass, your server module is valid Python with all imports resolved. If the first check fails, run pip install "mcp[cli]>=1.0.0". If the second check fails, look for syntax errors in filesystem_server.py.

Troubleshooting
  • ModuleNotFoundError: No module named 'mcp' → Run pip install "mcp[cli]>=1.0.0". Make sure your virtual environment is activated.
  • SyntaxError on async def → You need Python 3.10 or later. Check with python --version.
  • ImportError: cannot import name 'FastMCP' → You may have an older MCP SDK version. Run pip install --upgrade "mcp[cli]".

Step 2: Configure Claude Desktop

Now we tell Claude Desktop where to find your server. This step edits the Claude Desktop configuration file to add your server as a stdio subprocess. When Claude Desktop starts, it will launch your filesystem_server.py as a child process and perform the MCP handshake automatically. This step uses the server created in Step 1.

Open your claude_desktop_config.json file (location depends on your OS):

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/Claude/claude_desktop_config.json

Add (or create) the following configuration:

{
  "mcpServers": {
    "my-filesystem": {
      "command": "python",
      "args": ["/full/path/to/my-mcp-server/filesystem_server.py"],
      "env": {
        "ALLOWED_PATHS": "/home/user/projects"
      }
    }
  }
}
{
  "mcpServers": {
    "my-filesystem": {
      "command": "node",
      "args": ["/full/path/to/my-mcp-server/filesystem_server.js"],
      "env": {
        "ALLOWED_PATHS": "/home/user/projects"
      }
    }
  }
}
Important
  • Replace /full/path/to/ with the absolute path to your server file. Relative paths won't work because Claude Desktop launches from its own working directory.
  • Set ALLOWED_PATHS to a directory you want Claude to access. Never set it to / or C:\.
  • If you're using a virtual environment, set "command" to the full path of the venv Python: "/full/path/to/my-mcp-server/venv/bin/python"
Troubleshooting
  • Server won't connect / "Transport closed" → Run the server directly first: python filesystem_server.py. If it crashes immediately, you'll see the error. Common causes: missing MCP SDK, wrong Python path, syntax errors.
  • Tools don't appear in Claude Desktop → Make sure you fully quit and relaunch Claude Desktop (not just close the window). Check that command and args are separate fields (not combined).
  • "Path outside allowed directory" → The ALLOWED_PATHS env var must match the directory you're asking Claude to read. Use an absolute path.

Step 3: Verify in Claude Desktop

What & Why: This is the payoff — you'll see your custom server's tools, resource, and prompt appear in Claude Desktop's MCP panel. This confirms the handshake worked and Claude can now use your server.

Run Command:

  1. Fully quit Claude Desktop (not just close the window) and relaunch it
  2. Open a new conversation
  3. Click the MCP tools icon (hammer icon) in the input area — you should see your 3 tools listed under "my-filesystem"
  4. Type this prompt into Claude: "List the files in [your ALLOWED_PATHS directory]"
Expected Output
Claude should: 1. Show "my-filesystem" as a connected server in the MCP panel 2. List 3 tools: read_file, write_file, list_directory 3. Successfully call list_directory and show you the files 4. Ask for confirmation before calling write_file (since it has side effects)

Then try a more complex test — type: "Read the file [some-file-in-your-directory] and give me a summary". Claude should call read_file and produce a summary.

✅ Checkpoint

If Claude successfully lists files and reads a file from your server, the MCP handshake, tool discovery, and tool execution are all working. You've built a functional MCP server from scratch! If tools don't appear, see the troubleshooting section in Step 2 above.

Troubleshooting
  • No MCP tools icon visible → Your Claude Desktop version may not support MCP yet. Update to the latest version.
  • Server shows as "disconnected" → Open Claude Desktop's developer console (Help → Toggle Developer Tools) and check the MCP logs for error messages. The most common cause is a wrong Python path in your config.
  • Tools appear but calls fail → Make sure ALLOWED_PATHS in your config matches an actual directory on your machine. Use an absolute path, not a relative one.

Verify Everything Works

Run through this final checklist to confirm all 5 capabilities are working:

  1. Tools (3): Ask Claude to list a directory, read a file, and write a test file. All three should work.
  2. Resource (1): Check if the file tree resource appears in the MCP panel under "Resources." Claude can browse it without a tool call.
  3. Prompt (1): Check if "file_summary" appears under "Prompts" in the MCP panel. Use it to analyze a file with the structured template.
🎉 Congratulations

You've built a complete MCP server with all three primitives (Tools, Resources, Prompts) and connected it to Claude Desktop. Your server is now discoverable, callable, and reusable by any MCP-compatible client — not just Claude Desktop, but also Claude Code, Cursor, Zed, and others.

Stretch Goals (Optional)
  • Stretch 1: Build the SQLite database server (see Server 2 code in the walkthrough above) and connect both servers simultaneously. Verify Claude can read a file from one server and query data from the other in the same conversation.
  • Stretch 2: Use the MCP Inspector tool (mcp dev filesystem_server.py) to test your server without Claude Desktop — it shows every JSON-RPC message exchanged.
  • Stretch 3: Switch from stdio to SSE transport. Deploy your MCP server as a remote HTTP service and connect Claude to it via URL.

Knowledge Check

Test your understanding of MCP concepts. Choose the best answer for each question.

Q1: Which MCP primitive should you use to expose a database table's schema for browsing?

Tool — because it requires executing a query
Prompt — because it structures the model's analysis
Resource — because schema is read-only data with no side effects
Any primitive — it doesn't matter for read-only data
Correct! Resources are for read-only data without side effects. A schema is static data that clients browse, making it a perfect Resource.

Q2: What transport should you use for an MCP server that runs on a remote cloud VM?

stdio — it's the default and simplest
HTTP+SSE — it supports remote connections over the network
WebSocket — it provides bidirectional streaming
gRPC — it's optimized for server-to-server calls
Correct! HTTP+SSE is the MCP transport designed for remote servers. stdio requires the client to launch the server as a local subprocess, which isn't possible over a network.

Q3: What is the correct order of the MCP initialization handshake?

Server sends capabilities → Client acknowledges → Client sends initialize
Client sends tools/list → Server responds → Client sends initialized
Client sends initialize → Server responds with capabilities → Client sends initialized
Client sends ping → Server sends pong → Client sends initialize
Correct! The handshake is: (1) Client sends initialize with protocol version, (2) Server responds with its capabilities, (3) Client sends initialized to confirm.

Q4: What's wrong with this claude_desktop_config.json entry?

{
  "mcpServers": {
    "my-server": {
      "command": "python filesystem_server.py",
      "env": { "API_KEY": "sk-abc123..." }
    }
  }
}
The env variable name should be ANTHROPIC_API_KEY
The command and args should be separate — command should be "python" and args should be ["filesystem_server.py"]
MCP servers can't use environment variables
The server name must match the Python filename
Correct! In claude_desktop_config.json, command is the executable ("python") and args is an array of arguments (["filesystem_server.py"]). Putting both in command will fail.

Q5: Why does MCP convert tool integration from N×M to N+M? (Recall the integration problem from the animation.)

Because MCP makes all tools run faster
Because MCP eliminates the need for tool schemas
Because MCP bundles all tools into one server
Because each model and each tool only needs to implement MCP once, instead of custom integrations for every pair
Correct! With MCP, each of the N models implements the protocol once, and each of the M tools implements it once, giving N+M implementations instead of N×M custom pairings.

Q6: In M06, you learned about multi-tool orchestration with function calling. How does MCP improve on that approach?

MCP standardizes the protocol so tools from different servers are interoperable without custom integration code
MCP eliminates the need for JSON Schema tool definitions
MCP replaces function calling entirely
MCP makes tool calls synchronous instead of asynchronous
Correct! MCP doesn't replace function calling — it standardizes the protocol layer so tools from completely separate servers can be orchestrated together without writing custom glue code for each integration.
0/0

Module Summary

Key Concepts Recap

  • MCP (Model Context Protocol) is the "USB-C for AI" — a universal open standard for connecting AI models to tools and data sources.
  • MCP transforms integration from N×M (custom pairings) to N+M (each side implements once).
  • Three primitives: Resources (read-only data), Tools (executable actions), Prompts (reusable templates).
  • Communication uses JSON-RPC 2.0 over stdio (local) or HTTP+SSE (remote).
  • The handshake lifecycle: initialize → capabilities → initialized → operation → shutdown.
  • Multiple MCP servers can be connected simultaneously for cross-server orchestration.

What We Built

Two complete MCP servers — a filesystem server (read/write/list files, file tree resource, file summary prompt) and a SQLite database server (query, list tables, describe schema, data exploration prompt) — both configurable in Claude Desktop for seamless orchestration.

Next Module Preview

M08: Conversation Management — Now that your agent can use tools and connect to external systems via MCP, you need to manage multi-turn conversations effectively. You'll learn how to handle context windows, maintain conversation state, and implement strategies for long-running interactions.