#
tokens: 4451/50000 6/6 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .DS_Store
├── .gitignore
├── assets
│   └── demo.gif
├── LICENSE
├── pyproject.toml
├── README.md
└── src
    ├── .DS_Store
    └── notion_mcp
        ├── __init__.py
        ├── __main__.py
        ├── __pycache__
        │   ├── __init__.cpython-311.pyc
        │   ├── __main__.cpython-311.pyc
        │   └── server.cpython-311.pyc
        └── server.py
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Environment Variables
 2 | .env
 3 | .env.local
 4 | .env.*.local
 5 | .env.development
 6 | .env.test
 7 | .env.production
 8 | 
 9 | # Backup files
10 | .env.backup
11 | .env.*.backup
12 | 
13 | # IDE specific files
14 | .idea
15 | .vscode
16 | *.swp
17 | *.swo
18 | 
19 | # macOS system files
20 | .DS_Store
21 | .AppleDouble
22 | .LSOverride
23 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Notion MCP Integration
  2 | 
  3 | A simple Model Context Protocol (MCP) server that integrates with Notion's API to manage my personal todo list through Claude. This is a basic implementation tailored specifically for my minimalist todo list setup in Notion.
  4 | 
  5 | <p align="center">
  6 |   <img src="assets/demo.gif" width="600"/>
  7 | </p>
  8 | 
  9 | ## Important Note
 10 | 
 11 | This is a personal project designed for a very specific use case: my simple Notion todo list that has just three properties:
 12 | - Task (title)
 13 | - When (select with only two options: "today" or "later")
 14 | - Checkbox (marks if completed)
 15 | 
 16 | [Example Notion Database](https://danhilse.notion.site/14e5549555a08078afb5ed5d374bb656?v=14e5549555a081f9b5a4000cdf952cb9&pvs=4)
 17 | 
 18 | While you can use this as a starting point for your own Notion integration, you'll likely need to modify the code to match your specific database structure and requirements.
 19 | 
 20 | ## Features
 21 | 
 22 | - Add new todo items
 23 | - View all todos
 24 | - View today's tasks
 25 | - Check off a task as complete
 26 | 
 27 | ## Prerequisites
 28 | 
 29 | - Python 3.10 or higher
 30 | - A Notion account
 31 | - A Notion integration (API key)
 32 | - A Notion database that matches the exact structure described above (or willingness to modify the code for your structure)
 33 | 
 34 | ## Setup
 35 | 
 36 | 1. Clone the repository:
 37 | ```bash
 38 | git clone https://github.com/yourusername/notion-mcp.git
 39 | cd notion-mcp
 40 | ```
 41 | 
 42 | 2. Set up Python environment:
 43 | ```bash
 44 | python -m venv .venv
 45 | source .venv/bin/activate  # On Windows use: .venv\Scripts\activate
 46 | uv pip install -e .
 47 | ```
 48 | 
 49 | 3. Create a Notion integration:
 50 |    - Go to https://www.notion.so/my-integrations
 51 |    - Create new integration
 52 |    - Copy the API key
 53 | 
 54 | 4. Share your database with the integration:
 55 |    - Open your todo database in Notion
 56 |    - Click "..." menu → "Add connections"
 57 |    - Select your integration
 58 | 
 59 | 5. Create a `.env` file:
 60 | ```env
 61 | NOTION_API_KEY=your-api-key-here
 62 | NOTION_DATABASE_ID=your-database-id-here
 63 | ```
 64 | 
 65 | 6. Configure Claude Desktop:
 66 | ```json
 67 | {
 68 |   "mcpServers": {
 69 |     "notion-todo": {
 70 |       "command": "/path/to/your/.venv/bin/python",
 71 |       "args": ["-m", "notion_mcp"],
 72 |       "cwd": "/path/to/notion-mcp"
 73 |     }
 74 |   }
 75 | }
 76 | ```
 77 | 
 78 | ## Running the Server
 79 | 
 80 | The server can be run in two ways:
 81 | 
 82 | 1. Directly from the command line:
 83 | ```bash
 84 | # From the project directory with virtual environment activated
 85 | python -m notion_mcp
 86 | ```
 87 | 
 88 | 2. Automatically through Claude Desktop (recommended):
 89 | - The server will start when Claude launches if configured correctly in `claude_desktop_config.json`
 90 | - No manual server management needed
 91 | - Server stops when Claude is closed
 92 | 
 93 | Note: When running directly, the server won't show any output unless there's an error - this is normal as it's waiting for MCP commands.
 94 | 
 95 | ## Usage
 96 | 
 97 | Basic commands through Claude:
 98 | - "Show all my todos"
 99 | - "What's on my list for today?"
100 | - "Add a todo for today: check emails"
101 | - "Add a task for later: review project"
102 | 
103 | ## Limitations
104 | 
105 | - Only works with a specific Notion database structure
106 | - No support for complex database schemas
107 | - Limited to "today" or "later" task scheduling
108 | - No support for additional properties or custom fields
109 | - Basic error handling
110 | - No advanced features like recurring tasks, priorities, or tags
111 | 
112 | ## Customization
113 | 
114 | If you want to use this with a different database structure, you'll need to modify the `server.py` file, particularly:
115 | - The `create_todo()` function to match your database properties
116 | - The todo formatting in `call_tool()` to handle your data structure
117 | - The input schema in `list_tools()` if you want different options
118 | 
119 | ## Project Structure
120 | ```
121 | notion_mcp/
122 | ├── pyproject.toml
123 | ├── README.md
124 | ├── .env                   # Not included in repo
125 | └── src/
126 |     └── notion_mcp/
127 |         ├── __init__.py
128 |         ├── __main__.py
129 |         └── server.py      # Main implementation
130 | ```
131 | 
132 | ## License
133 | 
134 | MIT License - Use at your own risk
135 | 
136 | ## Acknowledgments
137 | 
138 | - Built to work with Claude Desktop
139 | - Uses Notion's API
140 | 
```

--------------------------------------------------------------------------------
/src/notion_mcp/__main__.py:
--------------------------------------------------------------------------------

```python
1 | from . import main
2 | 
3 | if __name__ == "__main__":
4 |     main()
```

--------------------------------------------------------------------------------
/src/notion_mcp/__init__.py:
--------------------------------------------------------------------------------

```python
1 | import asyncio
2 | from . import server
3 | 
4 | def main():
5 |     """Main entry point for the package."""
6 |     asyncio.run(server.main())
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [project]
 2 | name = "notion_mcp"
 3 | version = "0.1.0"
 4 | description = "Notion MCP integration for todo lists"
 5 | requires-python = ">=3.10"
 6 | dependencies = [
 7 |     "mcp",
 8 |     "httpx",
 9 |     "python-dotenv"
10 | ]
11 | 
12 | [build-system]
13 | requires = ["hatchling"]
14 | build-backend = "hatchling.build"
15 | 
16 | [tool.pytest.ini_options]
17 | asyncio_mode = "auto"
```

--------------------------------------------------------------------------------
/src/notion_mcp/server.py:
--------------------------------------------------------------------------------

```python
  1 | from mcp.server import Server
  2 | from mcp.types import (
  3 |     Resource, 
  4 |     Tool,
  5 |     TextContent,
  6 |     EmbeddedResource
  7 | )
  8 | from pydantic import AnyUrl
  9 | import os
 10 | import json
 11 | from datetime import datetime
 12 | import httpx
 13 | from typing import Any, Sequence
 14 | from dotenv import load_dotenv
 15 | from pathlib import Path
 16 | import logging
 17 | 
 18 | # Set up logging
 19 | logging.basicConfig(level=logging.DEBUG)
 20 | logger = logging.getLogger('notion_mcp')
 21 | 
 22 | # Find and load .env file from project root
 23 | project_root = Path(__file__).parent.parent.parent
 24 | env_path = project_root / '.env'
 25 | if not env_path.exists():
 26 |     raise FileNotFoundError(f"No .env file found at {env_path}")
 27 | load_dotenv(env_path)
 28 | 
 29 | # Initialize server
 30 | server = Server("notion-todo")
 31 | 
 32 | # Configuration with validation
 33 | NOTION_API_KEY = os.getenv("NOTION_API_KEY")
 34 | DATABASE_ID = os.getenv("NOTION_DATABASE_ID")
 35 | 
 36 | if not NOTION_API_KEY:
 37 |     raise ValueError("NOTION_API_KEY not found in .env file")
 38 | if not DATABASE_ID:
 39 |     raise ValueError("NOTION_DATABASE_ID not found in .env file")
 40 | 
 41 | NOTION_VERSION = "2022-06-28"
 42 | NOTION_BASE_URL = "https://api.notion.com/v1"
 43 | 
 44 | # Notion API headers
 45 | headers = {
 46 |     "Authorization": f"Bearer {NOTION_API_KEY}",
 47 |     "Content-Type": "application/json",
 48 |     "Notion-Version": NOTION_VERSION
 49 | }
 50 | 
 51 | async def fetch_todos() -> dict:
 52 |     """Fetch todos from Notion database"""
 53 |     async with httpx.AsyncClient() as client:
 54 |         response = await client.post(
 55 |             f"{NOTION_BASE_URL}/databases/{DATABASE_ID}/query",
 56 |             headers=headers,
 57 |             json={
 58 |                 "sorts": [
 59 |                     {
 60 |                         "timestamp": "created_time",
 61 |                         "direction": "descending"
 62 |                     }
 63 |                 ]
 64 |             }
 65 |         )
 66 |         response.raise_for_status()
 67 |         return response.json()
 68 | 
 69 | async def create_todo(task: str, when: str) -> dict:
 70 |     """Create a new todo in Notion"""
 71 |     async with httpx.AsyncClient() as client:
 72 |         response = await client.post(
 73 |             f"{NOTION_BASE_URL}/pages",
 74 |             headers=headers,
 75 |             json={
 76 |                 "parent": {"database_id": DATABASE_ID},
 77 |                 "properties": {
 78 |                     "Task": {
 79 |                         "type": "title",
 80 |                         "title": [{"type": "text", "text": {"content": task}}]
 81 |                     },
 82 |                     "When": {
 83 |                         "type": "select",
 84 |                         "select": {"name": when}
 85 |                     },
 86 |                     "Checkbox": {
 87 |                         "type": "checkbox",
 88 |                         "checkbox": False
 89 |                     }
 90 |                 }
 91 |             }
 92 |         )
 93 |         response.raise_for_status()
 94 |         return response.json()
 95 | 
 96 | async def complete_todo(page_id: str) -> dict:
 97 |     """Mark a todo as complete in Notion"""
 98 |     async with httpx.AsyncClient() as client:
 99 |         response = await client.patch(
100 |             f"{NOTION_BASE_URL}/pages/{page_id}",
101 |             headers=headers,
102 |             json={
103 |                 "properties": {
104 |                     "Checkbox": {
105 |                         "type": "checkbox",
106 |                         "checkbox": True
107 |                     }
108 |                 }
109 |             }
110 |         )
111 |         response.raise_for_status()
112 |         return response.json()
113 | 
114 | @server.list_tools()
115 | async def list_tools() -> list[Tool]:
116 |     """List available todo tools"""
117 |     return [
118 |         Tool(
119 |             name="add_todo",
120 |             description="Add a new todo item",
121 |             inputSchema={
122 |                 "type": "object",
123 |                 "properties": {
124 |                     "task": {
125 |                         "type": "string",
126 |                         "description": "The todo task description"
127 |                     },
128 |                     "when": {
129 |                         "type": "string",
130 |                         "description": "When the task should be done (today or later)",
131 |                         "enum": ["today", "later"]
132 |                     }
133 |                 },
134 |                 "required": ["task", "when"]
135 |             }
136 |         ),
137 |         Tool(
138 |             name="show_all_todos",
139 |             description="Show all todo items from Notion",
140 |             inputSchema={
141 |                 "type": "object",
142 |                 "properties": {},
143 |                 "required": []
144 |             }
145 |         ),
146 |         Tool(
147 |             name="show_today_todos",
148 |             description="Show today's todo items from Notion",
149 |             inputSchema={
150 |                 "type": "object",
151 |                 "properties": {},
152 |                 "required": []
153 |             }
154 |         ),
155 |         Tool(
156 |             name="complete_todo",
157 |             description="Mark a todo item as complete",
158 |             inputSchema={
159 |                 "type": "object",
160 |                 "properties": {
161 |                     "task_id": {
162 |                         "type": "string",
163 |                         "description": "The ID of the todo task to mark as complete"
164 |                     }
165 |                 },
166 |                 "required": ["task_id"]
167 |             }
168 |         )
169 |     ]
170 | 
171 | @server.call_tool()
172 | async def call_tool(name: str, arguments: Any) -> Sequence[TextContent | EmbeddedResource]:
173 |     """Handle tool calls for todo management"""
174 |     if name == "add_todo":
175 |         if not isinstance(arguments, dict):
176 |             raise ValueError("Invalid arguments")
177 |             
178 |         task = arguments.get("task")
179 |         when = arguments.get("when", "later")
180 |         
181 |         if not task:
182 |             raise ValueError("Task is required")
183 |         if when not in ["today", "later"]:
184 |             raise ValueError("When must be 'today' or 'later'")
185 |             
186 |         try:
187 |             result = await create_todo(task, when)
188 |             return [
189 |                 TextContent(
190 |                     type="text",
191 |                     text=f"Added todo: {task} (scheduled for {when})"
192 |                 )
193 |             ]
194 |         except httpx.HTTPError as e:
195 |             logger.error(f"Notion API error: {str(e)}")
196 |             return [
197 |                 TextContent(
198 |                     type="text",
199 |                     text=f"Error adding todo: {str(e)}\nPlease make sure your Notion integration is properly set up and has access to the database."
200 |                 )
201 |             ]
202 |             
203 |     elif name in ["show_all_todos", "show_today_todos"]:
204 |         try:
205 |             todos = await fetch_todos()
206 |             formatted_todos = []
207 |             for todo in todos.get("results", []):
208 |                 props = todo["properties"]
209 |                 formatted_todo = {
210 |                     "id": todo["id"],  # Include the page ID in the response
211 |                     "task": props["Task"]["title"][0]["text"]["content"] if props["Task"]["title"] else "",
212 |                     "completed": props["Checkbox"]["checkbox"],
213 |                     "when": props["When"]["select"]["name"] if props["When"]["select"] else "unknown",
214 |                     "created": todo["created_time"]
215 |                 }
216 |                 
217 |                 if name == "show_today_todos" and formatted_todo["when"].lower() != "today":
218 |                     continue
219 |                     
220 |                 formatted_todos.append(formatted_todo)
221 |             
222 |             return [
223 |                 TextContent(
224 |                     type="text",
225 |                     text=json.dumps(formatted_todos, indent=2)
226 |                 )
227 |             ]
228 |         except httpx.HTTPError as e:
229 |             logger.error(f"Notion API error: {str(e)}")
230 |             return [
231 |                 TextContent(
232 |                     type="text",
233 |                     text=f"Error fetching todos: {str(e)}\nPlease make sure your Notion integration is properly set up and has access to the database."
234 |                 )
235 |             ]
236 |     
237 |     elif name == "complete_todo":
238 |         if not isinstance(arguments, dict):
239 |             raise ValueError("Invalid arguments")
240 |             
241 |         task_id = arguments.get("task_id")
242 |         if not task_id:
243 |             raise ValueError("Task ID is required")
244 |             
245 |         try:
246 |             result = await complete_todo(task_id)
247 |             return [
248 |                 TextContent(
249 |                     type="text",
250 |                     text=f"Marked todo as complete (ID: {task_id})"
251 |                 )
252 |             ]
253 |         except httpx.HTTPError as e:
254 |             logger.error(f"Notion API error: {str(e)}")
255 |             return [
256 |                 TextContent(
257 |                     type="text",
258 |                     text=f"Error completing todo: {str(e)}\nPlease make sure your Notion integration is properly set up and has access to the database."
259 |                 )
260 |             ]
261 |     
262 |     raise ValueError(f"Unknown tool: {name}")
263 | 
264 | async def main():
265 |     """Main entry point for the server"""
266 |     from mcp.server.stdio import stdio_server
267 |     
268 |     if not NOTION_API_KEY or not DATABASE_ID:
269 |         raise ValueError("NOTION_API_KEY and NOTION_DATABASE_ID environment variables are required")
270 |     
271 |     async with stdio_server() as (read_stream, write_stream):
272 |         await server.run(
273 |             read_stream,
274 |             write_stream,
275 |             server.create_initialization_options()
276 |         )
277 | 
278 | if __name__ == "__main__":
279 |     import asyncio
280 |     asyncio.run(main())
```