MCP implementer’s guide: setting up servers, tools, and security patterns
Model Context Protocol (MCP) gives you a standard way to connect LLM applications to tools, data sources, and prompt templates — but the spec only gives you the wire format. This guide walks through building a real MCP server, exposing tools and resources, connecting to Claude Desktop and IDEs, and adding the security and operational layers that the protocol leaves to you.
If you are not yet familiar with MCP’s three primitives (tools, resources, prompts), read MCP explained: tools, resources, prompts and the current hype gap first. This guide assumes you understand the protocol shape and focuses on implementation.
TL;DR
Build and run an MCP server in four steps:
- Choose a transport — stdio (local subprocess) or SSE (remote server). Start with stdio for development.
- Install an SDK — Python (
mcp) or TypeScript (@modelcontextprotocol/sdk). - Define tools and resources — each tool gets a name, description, and JSON Schema input. Each resource gets a URI template.
- Add security and operations — authentication, tool-scope limiting, call logging, and test coverage before you connect a real client.
The walkthrough below uses Python and the official SDK, then covers TypeScript, Claude Desktop integration, and production patterns.
Prerequisites and setup
Python 3.10+ or Node 18+
pip install mcp # Python SDK
npm install @modelcontextprotocol/sdk # TypeScript SDK
The mcp Python package provides a server class, transport helpers, and a CLI runner. On TypeScript, the SDK exports Server, StdioServerTransport, and SSEServerTransport classes.
Project structure for this guide
mcp-demo-server/
├── server.py # main MCP server entry
├── tools/
│ ├── __init__.py
│ ├── files.py # file-system tools
│ └── search.py # search/query tools
├── resources/
│ ├── __init__.py
│ └── documents.py # resource providers
└── security/
├── auth.py # authentication layer
└── audit.py # tool-call audit logging
You do not need all these directories for a first server. This layout scales from a single-file prototype to a multi-tool multi-resource service.
Building your first MCP server (Python)
Minimal server with one tool
# server.py
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.server.stdio
server = Server("demo-server")
@server.list_tools()
async def handle_list_tools():
return [
{
"name": "read_file",
"description": "Read the contents of a file at a given path",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path to the file to read"
}
},
"required": ["path"]
}
}
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
if name == "read_file":
path = arguments["path"]
try:
with open(path, "r") as f:
content = f.read()
return {"content": [{"type": "text", "text": content}]}
except FileNotFoundError:
return {"content": [{"type": "text", "text": f"File not found: {path}"}],
"isError": True}
except PermissionError:
return {"content": [{"type": "text", "text": f"Permission denied: {path}"}],
"isError": True}
raise ValueError(f"Unknown tool: {name}")
async def main():
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="demo-server",
server_version="0.1.0"
)
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
Run with:
python server.py
The server starts on stdio and waits for a client to connect. To test it manually, use the mcp CLI inspector:
mcp dev server.py
This opens a web inspector at http://localhost:5173 where you can list tools, inspect schemas, and make test calls.
Adding resources
Resources are data the server can serve on demand. They are identified by URI and require no model reasoning — the client asks and the server responds.
@server.list_resources()
async def handle_list_resources():
return [
{
"uri": "file:///etc/hostname",
"name": "Hostname",
"mimeType": "text/plain",
"description": "System hostname"
}
]
@server.read_resource()
async def handle_read_resource(uri: str):
if uri == "file:///etc/hostname":
with open("/etc/hostname", "r") as f:
content = f.read().strip()
return {
"contents": [{
"uri": uri,
"mimeType": "text/plain",
"text": content
}]
}
raise ValueError(f"Unknown resource: {uri}")
For dynamic resources (e.g., query results), build URI templates and resolve them at read time rather than pre-registering every possible URI.
Adding prompts
Prompts give the model structured instruction templates. They are useful for standardising how the model formats responses for recurring tasks.
@server.list_prompts()
async def handle_list_prompts():
return [
{
"name": "summarise_log",
"description": "Summarise a log file focusing on errors and warnings",
"arguments": [
{
"name": "log_path",
"description": "Path to the log file",
"required": True
}
]
}
]
@server.get_prompt()
async def handle_get_prompt(name: str, arguments: dict):
if name == "summarise_log":
return {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": f"Read the file at {arguments['log_path']} and summarise the errors and warnings chronologically. Group by severity."
}
}
]
}
raise ValueError(f"Unknown prompt: {name}")
Building with TypeScript
The TypeScript SDK follows the same pattern but uses a class-based API:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "demo-server", version: "0.1.0" },
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [{
name: "read_file",
description: "Read the contents of a file",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Absolute path to the file" }
},
required: ["path"]
}
}]
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "read_file") {
const path = request.params.arguments?.path as string;
// ... read file ...
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
The TypeScript SDK also ships SSE transport (SSEServerTransport) for remote MCP servers.
Connecting to clients
Claude Desktop
Claude Desktop is the most common MCP client. Configure it by adding a mcpServers entry to your Claude Desktop config file:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"demo-server": {
"command": "python",
"args": ["/path/to/server.py"],
"env": {
"MCP_LOG_LEVEL": "error"
}
}
}
}
Key configuration rules for Claude Desktop:
- The
commandmust be an absolute orPATH-resolvable executable path. Relative paths like./server.pyfail silently. - Use
python -u(unbuffered) for real-time log output during debugging, then switch topythonfor production. - Set
MCP_LOG_LEVEL=errorto suppress SDK debug output that can interfere with the JSON-RPC stream. - Test your server standalone with
mcp dev server.pybefore adding it to Claude Desktop. If the inspector works, the config is probably correct.
VS Code and Cursor
VS Code supports MCP through the GitHub Copilot Chat extension (v0.22+). Cursor supports it through its MCP settings page.
VS Code: Add to .vscode/mcp.json or global VS Code MCP settings:
{
"servers": {
"demo-server": {
"type": "stdio",
"command": "python",
"args": ["/path/to/server.py"]
}
}
}
Cursor: Go to Settings → Features → MCP Servers, or edit ~/.cursor/mcp.json:
{
"mcpServers": {
"demo-server": {
"command": "python",
"args": ["/path/to/server.py"]
}
}
}
Both support the same stdio interface. The difference is in how each client handles tool-call approval — Cursor shows an inline approval prompt, VS Code routes through chat extensions.
Remote servers over SSE
For remote MCP servers (e.g., a shared tool service accessed by multiple clients):
from mcp.server import Server
import mcp.server.sse
server = Server("remote-demo")
# ... same tool/resource definitions ...
async def main():
async with mcp.server.sse.sse_server(
host="0.0.0.0",
port=8000
) as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="remote-demo",
server_version="0.1.0"
)
)
SSE servers use the same tool/resource/prompt handlers — only the transport changes. But do not expose an SSE server to a network without authentication (see security section below).
Security patterns
The MCP spec does not define authentication, authorisation, or audit. Every server needs these before it connects to a real client.
Authentication for SSE servers
SSE transport has no built-in auth. Add a token check at connection time:
from mcp.server.models import InitializationOptions
import os
AUTH_TOKEN = os.environ.get("MCP_AUTH_TOKEN", "")
async def main():
async with mcp.server.sse.sse_server(...) as (read_stream, write_stream):
# The SDK does not expose a pre-initialisation hook in all versions.
# Current pattern: validate on first tool/resource call.
# Better: wrap the SSE server with a reverse proxy that validates JWT/bearer tokens.
await server.run(read_stream, write_stream, ...)
A safer approach for SSE: put an API gateway or reverse proxy (Caddy, nginx, Envoy) in front of the MCP server that validates bearer tokens before forwarding requests. The MCP server itself runs on localhost and trusts the proxy.
Tool-scope limiting
Do not give the model access to every file or every database. Scope each tool to the minimum path or query set:
ALLOWED_PATHS = {"/home/user/project/data", "/home/user/project/config"}
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
if name == "read_file":
path = arguments["path"]
# Resolve to absolute and check prefix
abs_path = os.path.abspath(path)
if not any(abs_path.startswith(allowed) for allowed in ALLOWED_PATHS):
return {
"content": [{"type": "text", "text": f"Access denied: path outside allowed directories"}],
"isError": True
}
Tool-call audit logging
Log every tool call with timestamp, tool name, arguments, and result summary. This is essential for debugging and incident response:
import logging
logger = logging.getLogger("mcp.audit")
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
logger.info("tool_call", extra={
"tool": name,
"args": arguments,
"timestamp": logging.Formatter.formatTime(...)
})
# ... execute tool ...
logger.info("tool_result", extra={
"tool": name,
"success": not result.get("isError"),
"result_size": len(str(result))
})
Send audit logs to a separate file or structured log sink (JSON to stdout, or a remote log service) so they are not mixed with application debug output.
Rate limiting
MCP does not impose rate limits on tool calls. A fast model can hammer your server in parallel. Add basic rate limiting per tool and per client:
import time
from collections import defaultdict
call_counts = defaultdict(list)
MAX_CALLS_PER_MINUTE = 30
def rate_limited(tool_name: str) -> bool:
now = time.time()
window = now - 60
call_counts[tool_name] = [t for t in call_counts[tool_name] if t > window]
if len(call_counts[tool_name]) >= MAX_CALLS_PER_MINUTE:
return True # rate limited
call_counts[tool_name].append(now)
return False
Testing and debugging
MCP inspector
The mcp CLI includes a development inspector:
mcp dev server.py
Opens a browser UI at http://localhost:5173 that lists all tools, resources, and prompts registered by your server, lets you call individual tools with custom arguments, and shows raw JSON-RPC messages. Start here before connecting to any client.
Client-side testing
After the inspector confirms your server works, test with a lightweight MCP client before connecting Claude Desktop:
# test_client.py
import asyncio
from mcp import ClientSession, StdioServerParameters
async def main():
async with ClientSession(StdioServerParameters(
command="python", args=["server.py"]
)) as session:
tools = await session.list_tools()
print(f"Tools: {len(tools.tools)}")
for tool in tools.tools:
print(f" - {tool.name}: {tool.description}")
result = await session.call_tool("read_file", {
"path": "/etc/hostname"
})
print(f"Result: {result.content}")
asyncio.run(main())
Common debugging pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
| Server starts then exits silently | SDK import error or syntax error | Run python server.py directly before wrapping in client |
| Claude Desktop shows “MCP server not found” | Command path not resolvable | Use absolute path, not relative |
| Tools list empty in client | Server crashed during initialize | Check server stdout for unhandled exceptions |
| ”Could not parse server response” | print() or logging output in stdio stream | Remove all print() calls; use logging to a file |
| Tool call returns wrong result | Model hallucinated arguments | Add stricter JSON Schema constraints; test with inspector first |
Production checklist
Before putting an MCP server in front of real users:
- Add authentication — bearer token validation or reverse proxy for SSE; local filesystem permissions for stdio.
- Scope every tool to the minimum file/query set it needs.
- Implement audit logging for every tool call with arguments and result summary.
- Add rate limiting per tool and per client session.
- Set environment variables in the client config (
envfield), not in the server code. - Use
MCP_LOG_LEVEL=errorin production to suppress debug output. - Test with the MCP inspector before connecting any client.
- Write a lightweight test client (see above) to validate tool contracts under load.
- Pin SDK versions in your requirements.txt or package.json — protocol and SDKs are under active development.
- Document tool descriptions precisely enough that a model can distinguish similar tools without trial-and-error calls.
Methodology
- Data checked: 2026-05-30
- Sources consulted: MCP specification v2025-11-25 (spec.modelcontextprotocol.io), Python MCP SDK (GitHub, modelcontextprotocol/python-sdk), TypeScript MCP SDK (GitHub, modelcontextprotocol/typescript-sdk), Claude Desktop MCP documentation (Anthropic, accessed 2026-05-29), Cursor MCP documentation (docs.cursor.com, accessed 2026-05-29), MCP quick-start guide (modelcontextprotocol.io, accessed 2026-05-29)
- Assumptions: Reader has basic Python or TypeScript proficiency. Reader is familiar with MCP’s three primitives (tools, resources, prompts) at the conceptual level. Code examples assume Python 3.10+ with the
mcpSDK package or Node 18+ with@modelcontextprotocol/sdk. - Limitations: This guide does not cover MCP server deployment on Kubernetes or cloud infrastructure. It does not cover multi-tenant isolation, horizontal scaling, or formal threat modelling. Authentication patterns shown are illustrative; production authentication should be audited by a security engineer.
- Jurisdiction: Global. MCP is an open specification maintained under the Model Context Protocol organisation. SDK references are cross-platform. Regulatory context (e.g., EU AI Act, GDPR implications of tool-accessed data) is noted where relevant.
Source list
- MCP specification v2025-11-25 — https://spec.modelcontextprotocol.io/ (accessed 2026-05-29)
- Python MCP SDK — https://github.com/modelcontextprotocol/python-sdk (accessed 2026-05-29)
- TypeScript MCP SDK — https://github.com/modelcontextprotocol/typescript-sdk (accessed 2026-05-29)
- MCP quick-start guide — https://modelcontextprotocol.io/quickstart (accessed 2026-05-29)
- Claude Desktop MCP configuration — https://docs.anthropic.com/en/docs/agents-and-tools/mcp (accessed 2026-05-29)
- Cursor MCP setup — https://docs.cursor.com/advanced/mcp (accessed 2026-05-29)
Trust Stack
- Last checked: 2026-05-30
- Corrections: Contact us to report errors
Change log
- 2026-05-30: 16-gate editorial review: added Methodology, Trust Stack, and two Editor’s Notes; corrected frontmatter reviewer field.
- 2026-05-29: First draft written with working Python and TypeScript examples, client integration walkthroughs, security patterns, and production checklist.
Related guides
- MCP explained: tools, resources, prompts and the current hype gap — conceptual background (read this first)
- Tool use and function calling: a practical implementation guide — how to implement LLM tool use without MCP
- Function calling and tool use: where agents actually fail — failure modes in tool-based agents
- Tool-use safety: stopping agents from taking dangerous actions — safety patterns for tool-equipped agents