Your LLM is smart but trapped. It can write code, summarize documents, and answer questions, but it can't read a file on your disk, check your database, or fetch live data from an API. Function calling exists, sure. But every provider does it differently. OpenAI has one format. Anthropic has another. Switch models, rewrite your tool definitions.
Model Context Protocol (MCP) fixes this. One standard. Any LLM. Any tool.
What Is MCP?
MCP is an open protocol from Anthropic, released in late 2024. Think of it as USB-C for LLM tools. Instead of wiring each tool to each LLM with custom code, you build one MCP server and every MCP-compatible client can use it.
Three things an MCP server can expose:
- Tools — functions the LLM calls. Read a file, query a database, send a Slack message.
- Resources — structured data the LLM reads. Config values, API responses, file contents.
- Prompts — reusable templates. Code review checklists, translation instructions, summarization formats.
The LLM decides when to use which tool based on what you ask. You don't program the workflow. You define what the tools do and the model figures out the rest.
Prerequisites
- Python 3.10 or newer (
python --version) - pip or uv for package management
- Node.js 18+ (for the MCP Inspector testing tool)
- Claude Desktop (optional, for end-to-end testing)
Step 1: Create the Project
mkdir mcp-filesystem-server
cd mcp-filesystem-server
python -m venv .venv
source .venv/bin/activate
pip install mcp httpx
The mcp package is Anthropic's official Python SDK. httpx gives us async HTTP for the URL-fetching tool.
Step 2: Write Your First Tool
Create server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Filesystem Tools")
@mcp.tool()
def add(a: float, b: float) -> str:
"""Add two numbers."""
return str(a + b)
if __name__ == "__main__":
mcp.run()
FastMCP is the quickest way to build a server. Decorate functions with @mcp.tool(), @mcp.resource(), or @mcp.prompt(). The docstring becomes the tool description the LLM sees. Type hints define the parameter schema.
Run it:
python server.py
This starts a stdio server. It waits for MCP protocol messages on stdin and responds on stdout. Not much to see yet. We need a client to talk to it.
Step 3: Add Real Tools
Replace server.py with something that actually does useful work:
import httpx
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Filesystem Tools")
@mcp.tool()
def read_file(path: str) -> str:
"""Read the contents of a file at the given path."""
file_path = Path(path).expanduser().resolve()
if not file_path.exists():
return f"Error: {file_path} does not exist"
if file_path.is_dir():
return f"Error: {file_path} is a directory, not a file"
if file_path.stat().st_size > 1_000_000:
return f"Error: file is too large ({file_path.stat().st_size} bytes)"
return file_path.read_text()
@mcp.tool()
def list_directory(path: str = ".") -> str:
"""List files and directories at the given path."""
dir_path = Path(path).expanduser().resolve()
if not dir_path.exists():
return f"Error: {dir_path} does not exist"
if not dir_path.is_dir():
return f"Error: {dir_path} is not a directory"
items = []
for item in sorted(dir_path.iterdir()):
kind = "dir" if item.is_dir() else "file"
size = item.stat().st_size if item.is_file() else 0
items.append(f"{kind:4s} {size:>10,} {item.name}")
return "\n".join(items)
@mcp.tool()
async def fetch_url(url: str) -> str:
"""Fetch content from a URL and return it as text."""
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, follow_redirects=True)
response.raise_for_status()
return response.text[:5000]
@mcp.tool()
def search_files(directory: str, pattern: str) -> str:
"""Search for files matching a glob pattern in a directory."""
dir_path = Path(directory).expanduser().resolve()
if not dir_path.is_dir():
return f"Error: {dir_path} is not a directory"
matches = sorted(dir_path.rglob(pattern))
if not matches:
return f"No files matching '{pattern}' found"
return "\n".join(str(m) for m in matches[:50])
if __name__ == "__main__":
mcp.run()
Four tools on one server. The LLM can now read files, browse directories, fetch URLs, and search for files by pattern. The type hints tell the LLM what parameters each tool expects. The docstrings tell it when to use them.
Step 4: Test with MCP Inspector
Writing servers without a UI is frustrating. The MCP Inspector is a browser tool that connects to your server and lets you click through every tool:
npx @modelcontextprotocol/inspector python server.py
Opens at http://localhost:5173. Click Connect, pick your transport (stdio), and you see a list of all tools. Click any tool, fill in parameters, hit Run. Results, errors, and timing show up in the panel.
This is the fastest feedback loop for development. Change a tool, restart, test. No Claude Desktop config needed.
Step 5: Connect to Claude Desktop
Now wire it up to a real LLM.
Locate Claude Desktop's config file:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json(if using the Linux build)
{
"mcpServers": {
"filesystem-tools": {
"command": "python",
"args": ["/home/youruser/mcp-filesystem-server/server.py"]
}
}
}
The path must be absolute. Restart Claude Desktop. A small plug icon appears near the chat input. Click it and you see your server listed with its tool count.
Try this prompt:
Read the file at ~/projects/todo.md and list the top 3 priorities.
Claude calls read_file, gets the contents, parses out priorities, and answers. From Claude's perspective, it just read a file. From your perspective, you gave your LLM filesystem access in 15 minutes.
When to Use MCP vs Alternatives
MCP is not always the right answer.
Use MCP when:
- You want tools shared across multiple LLM clients (Claude, Cursor, Continue.dev, Zed, custom agents)
- You're building tool libraries that multiple people or teams will use
- You want a standard protocol your whole org can adopt
- You need resources and prompts, not just tools
Use direct function calling (OpenAI/Anthropic APIs) when:
- You're building one application with one LLM provider
- Latency is critical (MCP's process-spawning adds ~100-200ms)
- You have 1-2 simple tools and don't need the protocol overhead
- You're prototyping and just want something working
Use LangChain/LangGraph tools when:
- You need complex multi-step agent orchestration
- You want built-in memory, streaming, and observability
- You already have a LangChain codebase
MCP and LangChain are complementary. You can use MCP servers as LangChain tools via the langchain-mcp integration.
Common Mistakes
Absolute paths in Claude config. Every time. Relative paths silently fail. The Claude Desktop app runs from its own working directory.
Not handling errors in tools. If your tool throws an unhandled exception, the LLM just sees "tool call failed." Return error strings instead of raising. The LLM reads the error and often self-corrects.
Large outputs. Returning 50MB of log file crashes the protocol. Add size limits to tools that could return big results. Truncate or paginate.
File permissions. The MCP server process runs as your user. Tools that read files can read anything your user can. Tools that write files or execute commands need extra caution. Start read-only.
Ignoring the docstring. The LLM uses your docstring to decide when to call a tool. A bad docstring means the LLM won't know your tool exists. Write docstrings that describe what the tool does and when to use it.
Going Further
Once your server works, these are natural next steps:
- Add resources. Resources give the LLM read-only access to structured data without calling a tool. Config values, API docs, database schemas.
@mcp.resource("config://app")
def get_config() -> str:
return json.dumps({"version": "1.0", "max_file_size": 1_000_000})
- Add prompts. Prompts are reusable templates the LLM can pull in.
@mcp.prompt()
def code_review(file_path: str, language: str) -> str:
return f"Review {file_path} for bugs, style issues, and performance problems. Language: {language}."
Browse the MCP registry. The community has built servers for PostgreSQL, SQLite, Google Drive, Slack, GitHub, Brave Search, and dozens more. Check
github.com/modelcontextprotocol/servers.Deploy as HTTP server. For remote access, switch to SSE transport:
mcp.run(transport="sse", port=8080). Clients connect over HTTP instead of stdio.
Conclusion
Your LLM can now read files, list directories, fetch URLs, and search your filesystem, all through a protocol that any MCP-compatible client understands. No vendor lock-in. No rewriting tool definitions when you switch models.
The real power of MCP shows up when you chain tools. Ask Claude to "find all Python files modified this week, check them for TODO comments, and summarize what's left to do." It calls search_files, then read_file on each match, then synthesizes the answer.
What you build next is up to you. Database access, API integrations, deployment automation. The protocol stays the same. You just add tools.