├── .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())
--------------------------------------------------------------------------------