MCP Servers: Build Your First Server in 20 Minutes
Model Context Protocol (MCP) lets you give Claude custom tools — your databases, APIs, files, anything. This tutorial walks you through building a real Notion-to-Claude MCP server in Python: installation, working code, Claude Desktop connection, live conversation tests, and an n8n automation workflow. No prior MCP knowledge required.
By Talki Academy·Updated April 9, 2026
What is MCP and Why Build a Custom Server?
MCP (Model Context Protocol) is an open standard developed by Anthropic in late 2024 that defines how AI assistants communicate with external tools and data sources. Before MCP, integrating Claude with your own systems required custom prompt engineering, parsing ambiguous outputs, and writing brittle glue code for every integration. MCP standardizes this with a clean protocol.
Think of it this way: a standard power outlet doesn't need to know what device you plug in. MCP is that outlet for AI tools. You build a server that exposes capabilities (read a Notion page, query a database, send a Slack message), and any MCP-compatible AI client — Claude, VS Code Copilot, Cursor — can use those capabilities without additional configuration.
What you will build in this tutorial
A Python MCP server that connects Claude to your Notion workspace. Claude will be able to search pages, read content, create notes, and update pages — all from a natural language conversation. You'll also set up an n8n workflow that triggers this server automatically.
MCP Architecture in 30 Seconds
Three components interact in every MCP setup:
Host — the AI client (Claude Desktop, VS Code, Cursor) that orchestrates everything
Client — the MCP client embedded in the Host that manages connections to servers
Server — your program that exposes tools, resources, and prompts to the AI
Communication happens via JSON-RPC 2.0 over stdio (local subprocess) or HTTP/SSE (remote server). The LLM never calls your API directly — the MCP server acts as a controlled, sandboxed intermediary.
Define tools once, Claude calls them automatically
Multi-tool workflows
Complex orchestration logic, custom state management
Claude decides order and arguments natively
Reusability
Code tightly coupled to one prompt or app
Same server works in Claude Desktop, VS Code, Cursor, n8n
Security
Hard to audit what the LLM can access
Explicit tool list, sandboxed execution, no direct API access from LLM
Real-World Use Case: Notion + Claude
Notion is used by millions of teams as a knowledge base, project tracker, and personal wiki. The problem: searching and updating Notion manually breaks your flow. You switch apps, navigate to the right database, find the right page — and by then you've lost your train of thought.
With a Notion MCP server, you stay in Claude and say things like:
You:
Find my meeting notes from last Tuesday and summarize the action items.
Claude (using MCP tools):
I found "Team Sync — April 1, 2026" in your Meeting Notes database. Here are the action items:
1. Alexandre: finalize API schema by April 5 (owner: Alexandre) 2. Sophie: deliver mockups for onboarding screen v2 (due: April 8) 3. Thomas: schedule architecture review with the full team
Want me to create tasks in your Project Tracker for each of these?
The MCP server handles the Notion API calls transparently — Claude receives the page content as tool results and applies its reasoning on top.
Why FastMCP? The official MCP Python SDK ships with two APIs: the low-level Server class (verbose but flexible) and FastMCP (decorator-based, 3x less boilerplate). This tutorial uses FastMCP. Both are production-ready.
Name it "Claude MCP", select your workspace, click Save
Copy the Internal Integration Token (starts with secret_...)
Open the Notion pages/databases you want Claude to access → click the "..." menu → Connections → add "Claude MCP"
Important: Claude can only access pages you explicitly share with the integration. This is by design — it prevents accidental access to private notes. Share only the databases or pages you want Claude to read and write.
Build the Notion MCP Server
Create server.py in your project directory. The complete code below defines 5 tools: search pages, read a page, create a page, update a page, and list databases. Each tool is a Python async function decorated with @mcp.tool().
# server.py
import asyncio
import os
from mcp.server.fastmcp import FastMCP
from notion_client import AsyncClient
# Initialize FastMCP server
mcp = FastMCP("Notion Assistant")
# Initialize Notion client
# Set NOTION_API_KEY environment variable before running
notion = AsyncClient(auth=os.environ["NOTION_API_KEY"])
# ── TOOL 1: Search pages ──────────────────────────────────────────────────────
@mcp.tool()
async def search_notion(query: str) -> str:
"""Search for pages and databases in Notion by keyword.
Args:
query: The search term (e.g. "meeting notes", "project tracker")
Returns:
A formatted list of matching pages with titles and URLs.
"""
response = await notion.search(query=query, page_size=10)
if not response["results"]:
return f"No results found for '{query}'"
lines = [f"Found {len(response['results'])} results for '{query}':\n"]
for item in response["results"]:
item_type = item["object"]
if item_type == "page":
# Extract title from page properties
title = _get_page_title(item)
url = item.get("url", "")
lines.append(f"- [Page] {title}\n URL: {url}\n ID: {item['id']}")
elif item_type == "database":
title = item.get("title", [{}])[0].get("plain_text", "Untitled")
url = item.get("url", "")
lines.append(f"- [Database] {title}\n URL: {url}\n ID: {item['id']}")
return "\n".join(lines)
# ── TOOL 2: Read page content ─────────────────────────────────────────────────
@mcp.tool()
async def read_page(page_id: str) -> str:
"""Read the full content of a Notion page.
Args:
page_id: The Notion page ID (from search results or the page URL)
Returns:
The page title and all text content, block by block.
"""
# Clean up page ID (remove dashes if needed)
page_id = page_id.replace("-", "")
# Get page metadata
page = await notion.pages.retrieve(page_id=page_id)
title = _get_page_title(page)
# Get all blocks (page content)
blocks_response = await notion.blocks.children.list(block_id=page_id)
blocks = blocks_response["results"]
lines = [f"# {title}\n"]
for block in blocks:
block_type = block["type"]
if block_type == "paragraph":
text = _extract_rich_text(block["paragraph"]["rich_text"])
if text:
lines.append(text)
elif block_type in ("heading_1", "heading_2", "heading_3"):
level = block_type[-1]
text = _extract_rich_text(block[block_type]["rich_text"])
lines.append(f"{'#' * int(level)} {text}")
elif block_type == "bulleted_list_item":
text = _extract_rich_text(block["bulleted_list_item"]["rich_text"])
lines.append(f"- {text}")
elif block_type == "numbered_list_item":
text = _extract_rich_text(block["numbered_list_item"]["rich_text"])
lines.append(f"• {text}")
elif block_type == "to_do":
text = _extract_rich_text(block["to_do"]["rich_text"])
checked = "✅" if block["to_do"]["checked"] else "☐"
lines.append(f"{checked} {text}")
elif block_type == "code":
text = _extract_rich_text(block["code"]["rich_text"])
lang = block["code"].get("language", "")
lines.append(f"\n```{lang}\n{text}\n```\n")
return "\n".join(lines)
# ── TOOL 3: Create a new page ─────────────────────────────────────────────────
@mcp.tool()
async def create_page(
parent_id: str,
title: str,
content: str,
) -> str:
"""Create a new page in a Notion database or as a sub-page.
Args:
parent_id: ID of the parent database or page
title: Title for the new page
content: Text content for the page body (plain text, use newlines for paragraphs)
Returns:
Confirmation with the URL of the created page.
"""
parent_id = parent_id.replace("-", "")
# Determine if parent is a database or page
try:
parent_obj = await notion.databases.retrieve(database_id=parent_id)
parent = {"database_id": parent_id}
title_property = "Name" # Default database title property
except Exception:
parent = {"page_id": parent_id}
title_property = "title"
# Build page blocks from content
blocks = []
for paragraph in content.split("\n\n"):
paragraph = paragraph.strip()
if paragraph:
blocks.append({
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{"type": "text", "text": {"content": paragraph}}]
}
})
# Create the page
response = await notion.pages.create(
parent=parent,
properties={
title_property: {
"title": [{"type": "text", "text": {"content": title}}]
}
},
children=blocks,
)
url = response.get("url", "")
return f"✅ Page '{title}' created successfully.\nURL: {url}\nID: {response['id']}"
# ── TOOL 4: List databases ────────────────────────────────────────────────────
@mcp.tool()
async def list_databases() -> str:
"""List all Notion databases shared with this integration.
Returns:
Names and IDs of all accessible databases.
"""
response = await notion.search(
filter={"value": "database", "property": "object"},
page_size=20
)
if not response["results"]:
return "No databases found. Make sure you shared at least one database with the integration."
lines = [f"Found {len(response['results'])} databases:\n"]
for db in response["results"]:
title = db.get("title", [{}])[0].get("plain_text", "Untitled")
db_id = db["id"]
url = db.get("url", "")
lines.append(f"- {title}\n ID: {db_id}\n URL: {url}")
return "\n".join(lines)
# ── TOOL 5: Append content to a page ─────────────────────────────────────────
@mcp.tool()
async def append_to_page(page_id: str, content: str) -> str:
"""Append new text content to an existing Notion page.
Args:
page_id: ID of the page to update
content: Text to append (use double newlines for paragraph breaks)
Returns:
Confirmation message.
"""
page_id = page_id.replace("-", "")
blocks = []
for paragraph in content.split("\n\n"):
paragraph = paragraph.strip()
if paragraph:
blocks.append({
"object": "block",
"type": "paragraph",
"paragraph": {
"rich_text": [{"type": "text", "text": {"content": paragraph}}]
}
})
if not blocks:
return "No content to append."
await notion.blocks.children.append(block_id=page_id, children=blocks)
return f"✅ Appended {len(blocks)} paragraph(s) to page {page_id}"
# ── Helper functions ──────────────────────────────────────────────────────────
def _get_page_title(page: dict) -> str:
"""Extract title from a Notion page object."""
properties = page.get("properties", {})
for prop in properties.values():
if prop.get("type") == "title":
rich_text = prop.get("title", [])
if rich_text:
return rich_text[0].get("plain_text", "Untitled")
return "Untitled"
def _extract_rich_text(rich_text_array: list) -> str:
"""Concatenate plain text from Notion rich_text array."""
return "".join(item.get("plain_text", "") for item in rich_text_array)
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
mcp.run()
What Each Tool Does
Tool
What it does
Example prompt
search_notion
Full-text search across pages and databases
"Find my notes about the Q1 review"
read_page
Reads all blocks from a specific page
"Read the contents of my roadmap page"
create_page
Creates a new page in a database or as a sub-page
"Create a meeting note for today's standup"
list_databases
Shows all databases accessible to the integration
"What databases do I have?"
append_to_page
Adds content to an existing page without overwriting
"Add action items to my meeting note"
Connect to Claude Desktop
Step 1: Find the config file location
# macOS
~/Library/Application Support/Claude/claude_desktop_config.json
# Windows
%APPDATA%\Claude\claude_desktop_config.json
# Linux
~/.config/Claude/claude_desktop_config.json
# If the file doesn't exist, create it (Claude Desktop creates this on first launch)
Step 2: Add your server to the config
Open claude_desktop_config.json (create it if missing) and add this configuration. Replace the paths with your actual values:
Use absolute paths. Claude Desktop launches the server as a subprocess — relative paths like ./venv/bin/python will not work. Run which python (after activating your venv) and pwd to get the correct absolute paths.
Step 3: Get the correct Python path
# In your terminal, with venv activated:
cd notion-mcp-server
source venv/bin/activate
which python
# → /Users/yourname/notion-mcp-server/venv/bin/python
# Get absolute path to server.py
pwd
# → /Users/yourname/notion-mcp-server
# Full path to server.py:
# /Users/yourname/notion-mcp-server/server.py
Step 4: Restart Claude Desktop
Fully quit Claude Desktop (Cmd+Q on macOS, not just close the window) and reopen it. In the bottom-left of the chat input, you should now see a tools icon with "notion-assistant" listed.
Don't see the tools icon? Check the Developer Console: View → Developer → Developer Console. Look for errors like "Failed to start server" which usually means a wrong path or missing environment variable.
Test with Live Conversations
Test 1: Discover your databases
You:
What Notion databases do I have access to?
Claude (uses list_databases tool):
I found 3 databases in your Notion workspace:
1. Meeting Notes — ID: abc123... 2. Project Tracker — ID: def456... 3. Personal Wiki — ID: ghi789...
Which one would you like to explore?
Test 2: Search and read content
You:
Search for my notes about the product launch and summarize the key decisions.
Claude (uses search_notion + read_page):
I found 2 relevant pages. After reading both, here are the key decisions from "Product Launch Q2":
• Launch date confirmed: May 15, 2026 • Target audience: SMB customers in EU market first • Pricing: EUR 49/month (no free tier at launch) • Go-to-market: email campaign + 3 partner webinars
Action item still open: legal review of Terms of Service (owner: not assigned).
Test 3: Create a structured note
You:
Create a meeting note in my Meeting Notes database for today's standup. Attendees: Alice, Bob, Carol. We discussed: API performance issue (owner: Bob, due Friday), new onboarding flow designs approved, Carol on leave next week.
The note includes attendees, discussion summary, and the action item (API performance fix, Bob, due Friday).
Automate with n8n
n8n is an open-source workflow automation tool (self-hostable, 400+ integrations) that lets you trigger Claude + your MCP server automatically. Here's a practical example: automatically summarize new Notion pages added to a database and post the summary to Slack.
Option A: n8n with Claude API + Tool Definitions (No HTTP server needed)
This approach uses n8n's Anthropic node to call Claude with tool definitions that mirror your MCP tools. It works without deploying an HTTP server.
# n8n workflow (JSON — import this into your n8n instance)
# Trigger: Notion database updated (new page added)
# Action: Claude reads the page and posts a Slack summary
# Step 1: Notion Trigger node
# - Event: "Page Added"
# - Database: your Meeting Notes database ID
# Step 2: Anthropic node
# - Model: claude-opus-4-5-20251001
# - Tools: define inline tool to read Notion page content
# - System prompt: "You are a meeting assistant. Summarize the meeting notes
# and extract: date, attendees, decisions, action items."
# - User message: "Summarize this meeting page: {{ $json.id }}"
# Step 3: Slack node
# - Channel: #meeting-summaries
# - Message: "{{ $json.message.content[0].text }}"
# To read the page in n8n's Anthropic tool, add this tool definition:
{
"name": "read_notion_page",
"description": "Read the content of a Notion page",
"input_schema": {
"type": "object",
"properties": {
"page_id": {"type": "string", "description": "The Notion page ID"}
},
"required": ["page_id"]
}
}
Option B: Expose MCP Server over HTTP (share across team)
FastMCP supports HTTP/SSE transport with one line change. This lets n8n, other team members, and remote workflows connect to your server:
# server.py — HTTP transport variant
# Replace the last line with:
if __name__ == "__main__":
# Local stdio (for Claude Desktop):
# mcp.run()
# HTTP/SSE (for n8n, remote access, team sharing):
mcp.run(transport="sse", host="0.0.0.0", port=8000)
# Run it:
# python server.py
# → MCP server running on http://0.0.0.0:8000
# Test the SSE endpoint:
# curl http://localhost:8000/sse
Once running, configure n8n's MCP Client node (available in n8n 1.60+) to connect to http://your-server:8000/sse. Your five Notion tools will appear automatically — no manual tool definition needed.
Practical n8n Workflow: Daily Notion Digest
Here's a complete workflow that runs every morning at 8 AM, reads pages updated yesterday, and emails you a summary:
Relative path in config or Claude not fully restarted
Use absolute paths. Quit with Cmd+Q (not just close window), reopen
ModuleNotFoundError: mcp
Wrong Python path — pointing to system Python, not venv
Run which pythonwith venv activated, use that path in config
APIResponseError: unauthorized
Wrong or missing Notion API key
Verify NOTION_API_KEY in config matches the key from notion.so/my-integrations
Claude says "I don't have access to that page"
Page not shared with the integration
Open the page in Notion → "..." menu → Connections → add "Claude MCP"
Tools visible but calls always fail
Server crashing silently on startup
Run python server.py directly in terminal to see the real error
HTTP server works but stdio doesn't
Different transport configs
Make sure mcp.run() (no args) for stdio; mcp.run(transport="sse") for HTTP
Debug mode: see every tool call
# Add this to the top of server.py for verbose logging
import logging
logging.basicConfig(level=logging.DEBUG)
# Or check Claude Desktop logs:
# macOS: ~/Library/Logs/Claude/mcp-server-notion-assistant.log
# Windows: %APPDATA%\Claude\Logs\
# Run server manually to test:
NOTION_API_KEY=secret_xxx python server.py
# → Should print: "Starting MCP server 'Notion Assistant'"
# → If it crashes immediately, the error message tells you exactly what to fix
Next Steps
Extend your Notion server
Query databases — use notion.databases.query() to filter by property values (e.g., "all tasks due this week")
Update page properties — change status, assignee, due date on database items
Bulk operations — archive multiple pages, move items between databases
Webhooks — Notion webhooks (beta) can trigger n8n workflows in real-time
Build other MCP servers
GitHub MCP — search issues, create PRs, comment on reviews from Claude
PostgreSQL MCP — natural language database queries with asyncpg
Slack MCP — search messages, post to channels, create canvases
Email MCP — read/send emails via Gmail API or IMAP
There are over 2,000 open-source MCP servers already available. Browse the official MCP server registry before building from scratch.
Go deeper with Claude API
This tutorial uses Claude Desktop for local testing. For production systems — multi-user apps, automated pipelines, custom UIs — you'll integrate directly with the Claude API. Our Claude API for Developers training covers the full API (Messages, Tool Use, streaming), advanced MCP patterns, and production deployment. 3 days, financing available.
Frequently Asked Questions
Do I need to know Python to build an MCP server?
Basic Python is enough — if you understand functions, dictionaries, and async/await, you can follow this tutorial. The FastMCP decorator syntax (used here) is intentionally simple. If you prefer TypeScript, Anthropic also provides an official TypeScript SDK with very similar API.
Is the Notion API free to use?
Yes. Notion's public API is free with no usage limits for reasonable personal/team use. You create a free Notion integration at notion.so/my-integrations, get an API key, and share specific pages/databases with the integration. No credit card required.
Can I use this MCP server with tools other than Claude Desktop?
Yes. MCP is an open standard. Your server works with any MCP-compatible client: VS Code (Copilot Chat with MCP extension), Cursor, Windsurf, Zed, and JetBrains IDEs. For n8n automation, you can expose the server over HTTP/SSE transport and call it from any workflow tool.
What's the difference between MCP and direct API calls to Notion?
MCP is a communication protocol between an AI assistant and tool providers. Without MCP, you'd write custom code to parse Claude's output, detect intent, and call Notion APIs. With MCP, Claude itself decides which tools to call and formats the arguments — you just define the tools. This removes ~80% of the glue code and makes the system robust to prompt variations.
How do I move from local (stdio) to production (HTTP) transport?
FastMCP supports both transports with minimal code changes. For HTTP/SSE: replace `mcp.run()` with `mcp.run(transport='sse', host='0.0.0.0', port=8000)`. Then configure Claude Desktop or n8n to point to `http://your-server:8000/sse` instead of running a subprocess. This lets you share the server across a team or call it from remote workflows.