├── .DS_Store ├── .gitignore ├── LICENSE ├── README.md ├── assets └── demo.gif ├── pyproject.toml └── src ├── .DS_Store └── notion_mcp ├── __init__.py ├── __main__.py ├── __pycache__ ├── __init__.cpython-311.pyc ├── __main__.cpython-311.pyc └── server.cpython-311.pyc └── server.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhilse/notion_mcp/fec49b95f9a1eb7d27db9433af0b61bc2354f398/.DS_Store -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dan Hilse 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 |

6 | 7 |

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 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhilse/notion_mcp/fec49b95f9a1eb7d27db9433af0b61bc2354f398/assets/demo.gif -------------------------------------------------------------------------------- /pyproject.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/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhilse/notion_mcp/fec49b95f9a1eb7d27db9433af0b61bc2354f398/src/.DS_Store -------------------------------------------------------------------------------- /src/notion_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from . import server 3 | 4 | def main(): 5 | """Main entry point for the package.""" 6 | asyncio.run(server.main()) -------------------------------------------------------------------------------- /src/notion_mcp/__main__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | 3 | if __name__ == "__main__": 4 | main() -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/__init__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhilse/notion_mcp/fec49b95f9a1eb7d27db9433af0b61bc2354f398/src/notion_mcp/__pycache__/__init__.cpython-311.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/__main__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhilse/notion_mcp/fec49b95f9a1eb7d27db9433af0b61bc2354f398/src/notion_mcp/__pycache__/__main__.cpython-311.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/server.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danhilse/notion_mcp/fec49b95f9a1eb7d27db9433af0b61bc2354f398/src/notion_mcp/__pycache__/server.cpython-311.pyc -------------------------------------------------------------------------------- /src/notion_mcp/server.py: -------------------------------------------------------------------------------- 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()) --------------------------------------------------------------------------------