Why Build a Custom MCP Server?
The MCPgee directory lists 40+ pre-built MCP servers, but sometimes you need something tailored to your workflow. Maybe you need to connect Claude to your internal knowledge base, a proprietary database, or a domain-specific tool that no existing server covers. Building your own MCP server gives you full control over what data and actions your AI assistant can access.
In this guide, we are not building a toy weather stub. We are building a fully functional Notes Server that stores and retrieves notes with tags, full-text search, and timestamps. By the end, you will have a server that covers all three MCP primitives - Tools, Resources, and Prompts - tested with pytest, configured for Claude Desktop and Cursor, and ready to publish on PyPI.
If you are new to MCP, start with our What is MCP? tutorial first. Already comfortable with the basics? Let us dive straight in.
Prerequisites
- Python 3.10 or higher
- Basic Python knowledge - functions, classes, async/await, and type hints
- A text editor or IDE (we recommend setting up Cursor or VS Code with MCP)
uv(recommended) orpipfor package management- Familiarity with JSON Schema (used for tool input definitions)
Project Structure
Before writing any code, let us set up a clean project structure. This is what the final project will look like:
mcp-server-notes/
├── pyproject.toml
├── README.md
├── src/
│ └── mcp_server_notes/
│ ├── __init__.py
│ └── server.py
└── tests/
├── __init__.py
└── test_server.py
Create the directories and files now:
mkdir -p mcp-server-notes/src/mcp_server_notes mcp-server-notes/tests
cd mcp-server-notes
touch src/mcp_server_notes/__init__.py tests/__init__.py
The Complete pyproject.toml
Create pyproject.toml at the project root. This defines your package metadata, dependencies, and the console script entry point that MCP clients use to launch your server:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mcp-server-notes"
version = "0.1.0"
description = "An MCP server for storing, tagging, and searching notes"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Your Name", email = "you@example.com" }
]
keywords = ["mcp", "notes", "ai", "claude"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"mcp>=1.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
]
[project.scripts]
mcp-server-notes = "mcp_server_notes.server:main"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
Notice the [project.scripts] section. This creates a CLI command called mcp-server-notes that points to the main function in your server module. MCP clients like Claude Desktop use this command to start your server.
Building the Notes Server - Full Code
Here is the complete server implementation. Create this as src/mcp_server_notes/server.py. We will walk through every section after the full listing:
"""
MCP Notes Server - store, tag, search, and retrieve notes.
Exposes all three MCP primitives:
- Tools: create_note, search_notes, delete_note
- Resources: notes://all (live note listing)
- Prompts: summarize-notes (pre-built prompt template)
"""
from __future__ import annotations
import asyncio
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
EmbeddedResource,
GetPromptResult,
Prompt,
PromptArgument,
PromptMessage,
Resource,
TextContent,
Tool,
)
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("mcp-server-notes")
# ---------------------------------------------------------------------------
# In-memory note store
# ---------------------------------------------------------------------------
NoteDict = dict[str, Any]
_notes: dict[str, NoteDict] = {}
def _make_note(title: str, body: str, tags: list[str] | None = None) -> NoteDict:
"""Create a note dict with an auto-generated id and timestamp."""
note_id = uuid.uuid4().hex[:8]
now = datetime.now(timezone.utc).isoformat()
note: NoteDict = {
"id": note_id,
"title": title,
"body": body,
"tags": [t.strip().lower() for t in (tags or [])],
"created_at": now,
"updated_at": now,
}
_notes[note_id] = note
logger.info("Created note %s: %s", note_id, title)
return note
def _search(query: str | None = None, tag: str | None = None) -> list[NoteDict]:
"""Return notes matching an optional text query and/or tag filter."""
results = list(_notes.values())
if tag:
tag_lower = tag.strip().lower()
results = [n for n in results if tag_lower in n["tags"]]
if query:
q = query.lower()
results = [
n
for n in results
if q in n["title"].lower() or q in n["body"].lower()
]
# Most recent first
results.sort(key=lambda n: n["created_at"], reverse=True)
return results
# ---------------------------------------------------------------------------
# Server instance
# ---------------------------------------------------------------------------
app = Server("notes-server")
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
@app.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="create_note",
description=(
"Create a new note with a title, body, and optional tags. "
"Returns the full note object including its generated id."
),
inputSchema={
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short title for the note",
},
"body": {
"type": "string",
"description": "The note content (Markdown supported)",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of tags for categorization",
},
},
"required": ["title", "body"],
},
),
Tool(
name="search_notes",
description=(
"Search stored notes by text query and/or tag. "
"Returns matching notes sorted by most recent first."
),
inputSchema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Free-text search across title and body",
},
"tag": {
"type": "string",
"description": "Filter by a single tag",
},
},
},
),
Tool(
name="delete_note",
description="Permanently delete a note by its id.",
inputSchema={
"type": "object",
"properties": {
"note_id": {
"type": "string",
"description": "The id of the note to delete",
},
},
"required": ["note_id"],
},
),
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "create_note":
title = arguments.get("title", "").strip()
body = arguments.get("body", "").strip()
if not title:
return [TextContent(type="text", text="Error: title is required")]
if not body:
return [TextContent(type="text", text="Error: body is required")]
tags = arguments.get("tags", [])
note = _make_note(title, body, tags)
return [TextContent(type="text", text=json.dumps(note, indent=2))]
if name == "search_notes":
query = arguments.get("query")
tag = arguments.get("tag")
if not query and not tag:
return [TextContent(
type="text",
text="Error: provide at least a query or a tag",
)]
results = _search(query=query, tag=tag)
return [TextContent(
type="text",
text=json.dumps(results, indent=2) if results else "No notes found.",
)]
if name == "delete_note":
note_id = arguments.get("note_id", "").strip()
if note_id not in _notes:
return [TextContent(
type="text",
text=f"Error: note '{note_id}' not found",
)]
deleted = _notes.pop(note_id)
logger.info("Deleted note %s", note_id)
return [TextContent(
type="text",
text=f"Deleted note: {deleted['title']}",
)]
raise ValueError(f"Unknown tool: {name}")
# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------
@app.list_resources()
async def list_resources() -> list[Resource]:
return [
Resource(
uri="notes://all",
name="All Notes",
description=(
"A live JSON listing of every note currently stored, "
"including ids, titles, tags, and timestamps."
),
mimeType="application/json",
),
]
@app.read_resource()
async def read_resource(uri: str) -> str:
if str(uri) == "notes://all":
all_notes = sorted(
_notes.values(), key=lambda n: n["created_at"], reverse=True
)
return json.dumps(all_notes, indent=2)
raise ValueError(f"Unknown resource: {uri}")
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
@app.list_prompts()
async def list_prompts() -> list[Prompt]:
return [
Prompt(
name="summarize-notes",
description=(
"Generate a structured summary of all notes, optionally "
"filtered by tag. Useful for daily stand-ups or reviews."
),
arguments=[
PromptArgument(
name="tag",
description="Optional tag to filter notes before summarizing",
required=False,
),
PromptArgument(
name="style",
description="Summary style: 'brief', 'detailed', or 'bullet'",
required=False,
),
],
),
]
@app.get_prompt()
async def get_prompt(name: str, arguments: dict | None = None) -> GetPromptResult:
if name != "summarize-notes":
raise ValueError(f"Unknown prompt: {name}")
args = arguments or {}
tag = args.get("tag")
style = args.get("style", "brief")
notes = _search(tag=tag)
notes_json = json.dumps(notes, indent=2) if notes else "No notes found."
style_instruction = {
"brief": "Write a concise 2-3 sentence summary.",
"detailed": "Write a thorough summary covering every note.",
"bullet": "Summarize each note as a single bullet point.",
}.get(style, "Write a concise summary.")
return GetPromptResult(
description=f"Summary of {len(notes)} note(s)",
messages=[
PromptMessage(
role="user",
content=TextContent(
type="text",
text=(
f"Here are the notes to summarize:\n\n"
f"{notes_json}\n\n"
f"{style_instruction}\n"
f"Group by tags where possible."
),
),
),
],
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
"""Launch the Notes MCP server over stdio."""
async def _run():
async with stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app.create_initialization_options(),
)
asyncio.run(_run())
if __name__ == "__main__":
main()
Code Walkthrough
Let us break down the key design decisions in this server:
In-memory store with UUIDs. The _notes dictionary uses short 8-character hex IDs generated by uuid4. For a production server, you would swap this out for SQLite, PostgreSQL, or any persistent backend. The in-memory approach keeps this tutorial focused on MCP mechanics rather than database setup.
Normalized tags. Tags are stripped and lowercased on creation. This prevents issues where "Python", "python", and " python " are treated as different tags. Small detail, but it makes the search tool far more reliable when an AI model passes user input straight through.
UTC timestamps. All timestamps use datetime.now(timezone.utc).isoformat(). Using UTC from the start avoids timezone bugs that are painful to fix later.
Error handling in tools. Each tool validates its inputs before doing any work and returns human-readable error messages as TextContent. This is critical because MCP clients display tool results directly to users. A traceback is confusing; a message like "Error: title is required" is actionable.
Read our security guide for more on input validation and secure coding practices in MCP servers.
Adding Resources - The Second MCP Primitive
Resources in MCP are data endpoints that AI models can read. Unlike tools, which perform actions, resources provide information. Think of them as read-only APIs. Our server exposes a single resource at notes://all that returns every stored note as JSON.
When Claude connects to your server, it sees the resource listed and can read it at any time to get context about what notes exist. This is particularly powerful for workflows where the model needs to reference existing data before taking action. For example, Claude might read notes://all to see which notes already exist before creating a new one, avoiding duplicates.
Resources use custom URI schemes. The notes:// prefix is arbitrary - you choose what makes sense for your domain. Some servers use file:// for filesystem access or db:// for database tables. The key is consistency and clarity.
You can expose multiple resources. For a more advanced notes server, you could add notes://tags to list all known tags, or notes://${noteId} as a template to fetch individual notes.
Adding Prompts - The Third MCP Primitive
Prompts are the most overlooked MCP primitive, yet they are incredibly useful. A prompt is a pre-built template that helps AI models perform specific tasks with your server's data. Think of them as reusable workflows.
Our server defines a summarize-notes prompt with two optional arguments: tag (to filter notes) and style (brief, detailed, or bullet). When a user selects this prompt in Claude Desktop, the client fetches the current notes, assembles the prompt text, and sends it to the model - all without the user having to write any instructions.
This is the key difference between prompts and tools. Tools are invoked by the AI model during a conversation. Prompts are invoked by the user (or client) to kick off a conversation with pre-loaded context. Use prompts when you want to offer users a one-click workflow like "summarize my notes" or "generate a weekly report."
Prompts can include PromptArgument entries that the client renders as form fields. Required arguments must be filled in before the prompt runs. Optional arguments have sensible defaults. This gives users control without requiring them to know your server's internal API.
Testing with pytest
MCP servers are async Python - testing them is straightforward with pytest-asyncio. Create tests/test_server.py:
"""Tests for the MCP Notes Server."""
import json
import pytest
# Import internals for unit testing
from mcp_server_notes.server import (
_make_note,
_notes,
_search,
app,
call_tool,
list_tools,
list_resources,
read_resource,
list_prompts,
get_prompt,
)
@pytest.fixture(autouse=True)
def clear_notes():
"""Reset the note store between tests."""
_notes.clear()
yield
_notes.clear()
# -------------------------------------------------------------------
# Unit tests for helper functions
# -------------------------------------------------------------------
class TestMakeNote:
def test_creates_note_with_id(self):
note = _make_note("Test", "Body text")
assert "id" in note
assert len(note["id"]) == 8
assert note["title"] == "Test"
assert note["body"] == "Body text"
def test_normalizes_tags(self):
note = _make_note("Test", "Body", tags=["Python", " AI ", "MCP"])
assert note["tags"] == ["python", "ai", "mcp"]
def test_timestamps_are_utc_iso(self):
note = _make_note("Test", "Body")
assert note["created_at"].endswith("+00:00")
def test_note_is_stored(self):
note = _make_note("Stored", "Should persist")
assert note["id"] in _notes
class TestSearch:
def test_search_by_query(self):
_make_note("Python Tips", "Use list comprehensions")
_make_note("Rust Tips", "Use iterators")
results = _search(query="python")
assert len(results) == 1
assert results[0]["title"] == "Python Tips"
def test_search_by_tag(self):
_make_note("Note A", "Body", tags=["work"])
_make_note("Note B", "Body", tags=["personal"])
results = _search(tag="work")
assert len(results) == 1
assert results[0]["title"] == "Note A"
def test_search_combined(self):
_make_note("Meeting Notes", "Discuss Q3 goals", tags=["work"])
_make_note("Shopping List", "Discuss groceries", tags=["personal"])
results = _search(query="discuss", tag="work")
assert len(results) == 1
assert results[0]["title"] == "Meeting Notes"
def test_search_returns_newest_first(self):
_make_note("First", "Body")
_make_note("Second", "Body")
results = _search(query="body")
assert results[0]["title"] == "Second"
# -------------------------------------------------------------------
# Integration tests for MCP handlers
# -------------------------------------------------------------------
class TestToolHandlers:
async def test_list_tools_returns_three(self):
tools = await list_tools()
names = [t.name for t in tools]
assert "create_note" in names
assert "search_notes" in names
assert "delete_note" in names
async def test_create_note_via_tool(self):
result = await call_tool("create_note", {
"title": "Test Note",
"body": "Created via tool",
"tags": ["test"],
})
data = json.loads(result[0].text)
assert data["title"] == "Test Note"
assert data["tags"] == ["test"]
assert "id" in data
async def test_create_note_missing_title(self):
result = await call_tool("create_note", {"title": "", "body": "No title"})
assert "Error" in result[0].text
async def test_search_notes_via_tool(self):
await call_tool("create_note", {
"title": "Searchable",
"body": "Find me",
"tags": ["demo"],
})
result = await call_tool("search_notes", {"tag": "demo"})
data = json.loads(result[0].text)
assert len(data) == 1
async def test_delete_note_via_tool(self):
create_result = await call_tool("create_note", {
"title": "To Delete",
"body": "Temporary",
})
note_id = json.loads(create_result[0].text)["id"]
delete_result = await call_tool("delete_note", {"note_id": note_id})
assert "Deleted" in delete_result[0].text
assert note_id not in _notes
async def test_delete_nonexistent_note(self):
result = await call_tool("delete_note", {"note_id": "nope"})
assert "Error" in result[0].text
class TestResourceHandlers:
async def test_list_resources(self):
resources = await list_resources()
assert len(resources) == 1
assert str(resources[0].uri) == "notes://all"
async def test_read_all_notes_resource(self):
_make_note("Resource Test", "Readable via resource")
result = await read_resource("notes://all")
data = json.loads(result)
assert len(data) == 1
assert data[0]["title"] == "Resource Test"
async def test_read_unknown_resource_raises(self):
with pytest.raises(ValueError, match="Unknown resource"):
await read_resource("notes://invalid")
class TestPromptHandlers:
async def test_list_prompts(self):
prompts = await list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "summarize-notes"
async def test_get_prompt_with_notes(self):
_make_note("Daily Standup", "Discussed roadmap", tags=["work"])
result = await get_prompt("summarize-notes", {"style": "bullet"})
text = result.messages[0].content.text
assert "Daily Standup" in text
assert "bullet point" in text
async def test_get_prompt_unknown_raises(self):
with pytest.raises(ValueError, match="Unknown prompt"):
await get_prompt("nonexistent", {})
Run the tests:
# Install dev dependencies
pip install -e ".[dev]"
# Run tests with verbose output
pytest -v
All 18 tests should pass. The autouse fixture ensures the note store is clean for every test, so tests never leak state into each other. This test suite covers every tool, resource, and prompt handler - plus edge cases like missing fields and deleting nonexistent notes.
Configuring Claude Desktop
To test your server with Claude Desktop, edit your configuration file. On macOS it is at ~/Library/Application Support/Claude/claude_desktop_config.json. On Linux, check ~/.config/claude/claude_desktop_config.json. On Windows, look in %APPDATA%\Claude\claude_desktop_config.json.
{
"mcpServers": {
"notes": {
"command": "uv",
"args": [
"run",
"--directory", "/absolute/path/to/mcp-server-notes",
"mcp-server-notes"
]
}
}
}
If you installed the package globally with pip install -e ., you can simplify to:
{
"mcpServers": {
"notes": {
"command": "mcp-server-notes"
}
}
}
Restart Claude Desktop after editing the config. You should see a hammer icon indicating tools are available. Try asking Claude: "Create a note titled 'Project Ideas' with the body 'Build a CLI tool for MCP testing' and tag it with 'dev'." Claude will call the create_note tool and show you the result.
Configuring Cursor
Cursor supports MCP servers through its settings. Create or edit .cursor/mcp.json in your project root:
{
"mcpServers": {
"notes": {
"command": "uv",
"args": [
"run",
"--directory", "/absolute/path/to/mcp-server-notes",
"mcp-server-notes"
]
}
}
}
Reload the Cursor window and the server will connect automatically. For detailed setup in other editors, see our IDE setup guide.
Python vs TypeScript MCP SDK - Feature Comparison
Choosing between the Python and TypeScript MCP SDKs? Here is a side-by-side comparison of features:
| Feature | Python SDK | TypeScript SDK |
|---|---|---|
| Package | pip install mcp |
npm install @modelcontextprotocol/sdk |
| Transport: stdio | Built-in | Built-in |
| Transport: SSE | Built-in | Built-in |
| Transport: Streamable HTTP | Supported | Supported |
| Async model | asyncio (native) | Promises / async-await |
| Type safety | Type hints + Pydantic models | Full TypeScript types + Zod schemas |
| Decorator-based API | Yes - @app.list_tools(), etc. | Method-based - server.setRequestHandler() |
| FastMCP high-level API | Yes - simplified decorators | Not available |
| Client SDK | Yes - ClientSession | Yes - Client class |
| Ecosystem maturity | Strong - rich library ecosystem | Strong - Node.js ecosystem |
| Best for | Data science, ML pipelines, scripting | Web services, Electron apps, full-stack |
Both SDKs are officially maintained and support the full MCP specification. Choose based on your existing stack and team expertise. If you are building a server that interacts with Python libraries like pandas, scikit-learn, or SQLAlchemy, the Python SDK is the natural choice. If your server wraps a Node.js service or lives in a TypeScript monorepo, go with the TypeScript SDK.
Publishing to PyPI
Once your server is tested and working, publishing it to PyPI lets anyone install it with a single pip install command. Here are the complete steps:
1. Prepare Your Package
Make sure your pyproject.toml has accurate metadata - name, version, description, author, and license. Double-check that [project.scripts] points to the correct entry point.
2. Build the Distribution
# Install build tools
pip install build twine
# Build source and wheel distributions
python -m build
This creates two files in the dist/ directory: a .tar.gz source archive and a .whl wheel file.
3. Test on TestPyPI First
# Upload to TestPyPI
twine upload --repository testpypi dist/*
# Test installation from TestPyPI
pip install --index-url https://test.pypi.org/simple/ mcp-server-notes
Verify the package installs correctly and the mcp-server-notes command works before publishing to the real PyPI.
4. Publish to PyPI
# Upload to PyPI (you will need a PyPI account and API token)
twine upload dist/*
After publishing, anyone can install your server with:
pip install mcp-server-notes
5. Configure for uvx
Users who prefer uvx can run your server without installing it globally:
uvx mcp-server-notes
This works automatically because your [project.scripts] entry defines the CLI command. No additional configuration is needed.
Error Handling Best Practices
Production MCP servers need robust error handling. Here are the patterns our Notes Server uses and additional recommendations:
- Validate early, fail clearly. Check required fields at the top of each tool handler and return descriptive error messages. Never let a
KeyErrorpropagate - the client will show a generic failure. - Use logging, not print. The
loggingmodule lets you control verbosity without changing code. MCP communicates over stdio, soprint()would corrupt the protocol stream. - Catch exceptions in tool handlers. Wrap external API calls in try/except blocks. Return error details as
TextContentso the AI model can explain the failure to the user. - Never expose internal state in errors. Tracebacks, file paths, and environment variables should stay in logs, not in tool responses. Read our security guide for more on this.
- Use async I/O everywhere. If your server makes HTTP requests, use
aiohttporhttpxinstead ofrequests. Blocking I/O in an async handler freezes the entire server.
Deploying with Docker
For production deployment, containerize your server:
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir .
CMD ["mcp-server-notes"]
Build and run:
docker build -t mcp-server-notes .
docker run -i mcp-server-notes
The -i flag is important - MCP stdio transport requires an interactive stdin. For more deployment options, see our Docker deployment tutorial and Kubernetes guide.
Real-World Examples
For inspiration, explore these production MCP servers in our directory:
- Stripe MCP - payment processing integration
- Notion MCP - workspace and document management
- Jira MCP - project management and issue tracking
- Slack MCP - team communication integration
- Shopify MCP - e-commerce data access
Each server's detail page on MCPgee shows configuration examples, security details, and usage patterns.
Common Pitfalls
- Forgetting async: MCP handlers must be async functions. A sync handler will silently break the event loop.
- Using print() for debugging: MCP uses stdio for communication. Any
print()call corrupts the JSON-RPC stream. Useloggingwith stderr output instead. - Missing input validation: Always validate and sanitize tool arguments. AI models can pass unexpected types or empty strings.
- No error boundaries: Unhandled exceptions crash the server connection. Wrap every tool handler in try/except.
- Blocking I/O: Use
aiohttporhttpxinstead ofrequestsfor HTTP calls. - Missing tool descriptions: Good descriptions help AI models use your tools correctly. Be specific about what each parameter does and what the tool returns.
- Hardcoded paths in configs: Always use absolute paths in Claude Desktop and Cursor configurations. Relative paths fail silently.
- Forgetting to restart the client: After changing your server code, you must restart Claude Desktop or reload Cursor for changes to take effect.
What's Next
You have built a complete MCP server that covers all three primitives - Tools, Resources, and Prompts - with proper testing, client configuration, and a path to publication. Here is where to go from here:
- Advanced Python MCP Server Development - authentication, database backends, and streaming
- Advanced MCP Features - sampling, roots, and multi-server setups
- Security Guide - harden your server for production
- MCP vs REST APIs - when to build an MCP server vs a traditional API
- Browse all servers for architecture inspiration
