Talki Academy
TutorialBeginner-friendly22 min read

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.

CapabilityWithout MCPWith MCP
Notion integrationParse Claude output manually, extract intent, call Notion API, handle errorsDefine tools once, Claude calls them automatically
Multi-tool workflowsComplex orchestration logic, custom state managementClaude decides order and arguments natively
ReusabilityCode tightly coupled to one prompt or appSame server works in Claude Desktop, VS Code, Cursor, n8n
SecurityHard to audit what the LLM can accessExplicit 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.

Prerequisites

Before you start, make sure you have:

  • Python 3.11+ — check with python --version
  • Claude Desktop installed — download here
  • A Notion account — free plan is fine
  • Basic Python knowledge — functions, dicts, async/await
# Verify Python version python --version # → Python 3.11.8 (or higher) pip --version # → pip 24.0 # If Python is not installed: # macOS: brew install python@3.11 # Ubuntu: sudo apt install python3.11 python3.11-venv # Windows: download from python.org

Install the Python MCP SDK

Step 1: Create a virtual environment

# Create project directory mkdir notion-mcp-server cd notion-mcp-server # Create virtual environment (keeps dependencies isolated) python -m venv venv # Activate it source venv/bin/activate # macOS / Linux # venv\Scripts\activate.bat # Windows # Verify activation — prompt should show (venv) which python # → /path/to/notion-mcp-server/venv/bin/python

Step 2: Install dependencies

# Install MCP SDK with FastMCP support pip install "mcp[cli]>=1.4.0" # Install Notion client pip install notion-client>=2.2.1 # Verify pip show mcp # → Name: mcp # → Version: 1.4.x pip show notion-client # → Name: notion-client # → Version: 2.2.x
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.

Step 3: Get your Notion API key

  1. Go to notion.so/my-integrations
  2. Click "New integration"
  3. Name it "Claude MCP", select your workspace, click Save
  4. Copy the Internal Integration Token (starts with secret_...)
  5. 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

ToolWhat it doesExample prompt
search_notionFull-text search across pages and databases"Find my notes about the Q1 review"
read_pageReads all blocks from a specific page"Read the contents of my roadmap page"
create_pageCreates a new page in a database or as a sub-page"Create a meeting note for today's standup"
list_databasesShows all databases accessible to the integration"What databases do I have?"
append_to_pageAdds 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:

{ "mcpServers": { "notion-assistant": { "command": "/path/to/notion-mcp-server/venv/bin/python", "args": ["/path/to/notion-mcp-server/server.py"], "env": { "NOTION_API_KEY": "secret_YOUR_NOTION_API_KEY_HERE" } } } }
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.
Claude (uses create_page tool):
✅ Meeting note created: "Standup — April 9, 2026"
URL: notion.so/your-workspace/standup-april-9...

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:

Workflow: Daily Notion Digest ────────────────────────────────────────────────────────── 1. Schedule Trigger Cron: 0 8 * * 1-5 # 8 AM weekdays 2. HTTP Request (search updated pages) Method: POST URL: http://your-mcp-server:8000/call_tool Body: { "tool": "search_notion", "arguments": {"query": "last modified:yesterday"} } 3. Split in Batches (process each page) Batch size: 1 4. HTTP Request (read each page) Method: POST URL: http://your-mcp-server:8000/call_tool Body: { "tool": "read_page", "arguments": {"page_id": "{{ $json.id }}"} } 5. Anthropic node Model: claude-haiku-4-5-20251001 (fastest, cheapest) Prompt: "Summarize in 3 bullet points: {{ $json.content }}" 6. Gmail / SMTP node To: you@yourcompany.com Subject: "Notion Daily Digest — {{ $today }}" Body: Aggregated summaries from step 5 Estimated cost: ~$0.002/day (Haiku pricing, 10 pages avg)

Troubleshooting

SymptomLikely causeFix
Server not listed in Claude DesktopRelative path in config or Claude not fully restartedUse absolute paths. Quit with Cmd+Q (not just close window), reopen
ModuleNotFoundError: mcpWrong Python path — pointing to system Python, not venvRun which python with venv activated, use that path in config
APIResponseError: unauthorizedWrong or missing Notion API keyVerify 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 integrationOpen the page in Notion → "..." menu → Connections → add "Claude MCP"
Tools visible but calls always failServer crashing silently on startupRun python server.py directly in terminal to see the real error
HTTP server works but stdio doesn'tDifferent transport configsMake 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.

Master MCP and the Claude API — 3-Day Training

Hands-on training for developers. Build production-ready AI integrations from day one.

View Claude API TrainingRequest Information