├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── notion-mcp-reqs.md ├── pyproject.toml ├── smithery.yaml └── src └── notion_mcp ├── __init__.py ├── __main__.py ├── __pycache__ ├── __init__.cpython-311.pyc ├── __init__.cpython-313.pyc ├── __main__.cpython-311.pyc ├── __main__.cpython-313.pyc ├── client.cpython-313.pyc ├── server.cpython-311.pyc └── server.cpython-313.pyc ├── client.py ├── models ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-313.pyc │ └── notion.cpython-313.pyc └── notion.py └── server.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .nox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | *.cover 40 | .hypothesis/ 41 | .pytest_cache/ 42 | 43 | # Pyre type checker 44 | .pyre/ 45 | 46 | # Jupyter Notebook 47 | .ipynb_checkpoints 48 | 49 | # pyenv 50 | .python-version 51 | 52 | # pipenv 53 | Pipfile.lock 54 | 55 | # poetry 56 | poetry.lock 57 | 58 | # mypy 59 | .mypy_cache/ 60 | .dmypy.json 61 | 62 | # dotenv 63 | .env 64 | .env.* 65 | 66 | # VS Code 67 | .vscode/ 68 | 69 | # MacOS 70 | .DS_Store 71 | 72 | # System files 73 | Thumbs.db 74 | desktop.ini 75 | 76 | # Local dev 77 | *.local 78 | 79 | # Logs 80 | *.log 81 | 82 | # uv environment 83 | .uv/ 84 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM python:3.10-slim 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy the project files 8 | COPY pyproject.toml ./ 9 | COPY src ./src 10 | COPY README.md ./ 11 | 12 | # Create a default .env file with a dummy API key to satisfy startup requirement. 13 | # In production, the NOTION_API_KEY env var will override this if mounted. 14 | RUN echo "NOTION_API_KEY=changeme" > .env 15 | 16 | # Upgrade pip and install the package in editable mode 17 | RUN pip install --upgrade pip \ 18 | && pip install -e . --no-cache-dir 19 | 20 | # Set environment variable placeholder for Notion API Key (can be overridden at runtime) 21 | ENV NOTION_API_KEY="changeme" 22 | 23 | # Command to run the MCP server 24 | CMD ["python", "-m", "notion_mcp"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chase Cabanillas 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 this 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 Server 2 | [![smithery badge](https://smithery.ai/badge/@ccabanillas/notion-mcp)](https://smithery.ai/server/@ccabanillas/notion-mcp) 3 | 4 | A Model Context Protocol (MCP) server implementation for Notion integration, providing a standardized interface for interacting with Notion's API. Compatible with Claude Desktop and other MCP clients. 5 | 6 | ## Features 7 | 8 | - List and query Notion databases 9 | - Create and update pages 10 | - Search across Notion workspace 11 | - Get database details and block children 12 | - Full async/await support with httpx 13 | - Type-safe with Pydantic v2 models 14 | - Proper error handling with detailed logging 15 | - Compatibility with MCP 1.6.0 16 | 17 | ## Installation 18 | 19 | ### Installing via Smithery 20 | 21 | To install Notion Integration Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@ccabanillas/notion-mcp): 22 | 23 | ```bash 24 | npx -y @smithery/cli install @ccabanillas/notion-mcp --client claude 25 | ``` 26 | 27 | ### Manual Installation 28 | 1. Clone the repository: 29 | ```bash 30 | git clone https://github.com/ccabanillas/notion-mcp.git 31 | cd notion-mcp 32 | ``` 33 | 34 | 2. Create a virtual environment and install dependencies (using uv): 35 | ```bash 36 | uv venv 37 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 38 | uv pip install -e . 39 | ``` 40 | 41 | Alternatively, using standard venv: 42 | ```bash 43 | python -m venv venv 44 | source venv/bin/activate # On Windows: venv\Scripts\activate 45 | pip install -e . 46 | ``` 47 | 48 | 3. Create a `.env` file in the project root: 49 | ```bash 50 | NOTION_API_KEY=your_notion_integration_token 51 | ``` 52 | 53 | ## Usage 54 | 55 | 1. Test the server (it should run without errors): 56 | ```bash 57 | python -m notion_mcp 58 | ``` 59 | 60 | 2. To use it with Claude Desktop, adjust your `claude_desktop_config.json` file (located at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): 61 | 62 | ```json 63 | { 64 | "servers": { 65 | "notion-mcp": { 66 | "command": "/Users/username/Projects/notion-mcp/.venv/bin/python", 67 | "args": ["-m", "notion_mcp"], 68 | "cwd": "/Users/username/Projects/notion-mcp" 69 | } 70 | } 71 | } 72 | ``` 73 | 74 | Be sure to replace `/Users/username/` with your actual home directory path. 75 | 76 | ## Development 77 | 78 | ### Project Structure 79 | 80 | ``` 81 | notion-mcp/ 82 | ├── src/ 83 | │ └── notion_mcp/ 84 | │ ├── models/ 85 | │ │ ├── __init__.py 86 | │ │ └── notion.py # Pydantic models for Notion objects 87 | │ ├── __init__.py 88 | │ ├── __main__.py # Entry point 89 | │ ├── client.py # Notion API client 90 | │ └── server.py # MCP server implementation 91 | ├── .env # Environment variables (add your Notion API key here) 92 | ├── .gitignore 93 | ├── pyproject.toml # Project dependencies 94 | └── README.md 95 | ``` 96 | 97 | ### Running Tests 98 | 99 | ```bash 100 | pytest 101 | ``` 102 | 103 | ## Configuration 104 | 105 | The server requires a Notion integration token. To set this up: 106 | 107 | 1. Go to https://www.notion.so/my-integrations 108 | 2. Create a new integration with appropriate capabilities (read/write as needed) 109 | 3. Copy the integration token 110 | 4. Add it to your `.env` file in the project root directory: 111 | 112 | ``` 113 | NOTION_API_KEY=your_notion_integration_token 114 | ``` 115 | 116 | 5. Share your Notion databases with the integration (from the database's "Share" menu) 117 | 118 | ## Contributing 119 | 120 | 1. Fork the repository 121 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 122 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 123 | 4. Push to the branch (`git push origin feature/amazing-feature`) 124 | 5. Open a Pull Request 125 | 126 | ## License 127 | 128 | MIT License - Use at your own risk 129 | 130 | ## Troubleshooting 131 | 132 | ### Common Issues 133 | 134 | - **Connection Errors**: Make sure your Notion API key is correct and you have internet access 135 | - **Permission Errors**: Ensure your integration has been given access to the databases you're trying to access 136 | - **Claude Desktop Integration**: If Claude Desktop isn't connecting, check that your config path is correct and that the server is running without logging to stdout 137 | 138 | ## Acknowledgments 139 | 140 | - Built to work with Claude Desktop and other MCP clients 141 | - Uses Notion's API (latest compatible version 2022-02-22) 142 | - MCP 1.6.0 compatibility maintained 143 | - Special thanks to [danhilse](https://github.com/danhilse), I referenced his [notion-mcp-server](https://github.com/danhilse/notion-mcp-server) project 144 | -------------------------------------------------------------------------------- /notion-mcp-reqs.md: -------------------------------------------------------------------------------- 1 | # MCP Notion Server Development Prompt 2 | 3 | Help me expand an existing Model Context Protocol server that will integrate with Notion's API. The server should: 4 | 5 | ## Core Functionality 6 | - Modify existing server exposing Notion operations as MCP resources and tools 7 | - Add Notion operations including: 8 | - Listing databases 9 | - Querying databases 10 | - Creating and updating pages 11 | - Handling blocks and content 12 | - Search functionality 13 | 14 | ## Technical Requirements 15 | - Use Python 3.10+ with async/await 16 | - Implement proper error handling and status codes 17 | - Include type hints and validation 18 | - Follow MCP protocol specifications for: 19 | - Resource definitions 20 | - Tool schemas 21 | - Response formats 22 | - Error handling 23 | 24 | ## Project Structure 25 | Base the implementation on the MCP weather server example, but adapt for Notion: 26 | - Use `notion-mcp` as project name 27 | - Include configuration for Notion API authentication 28 | - Structure handlers for different Notion object types 29 | 30 | ## Code Style 31 | Generate code that is: 32 | - Fully typed with pydantic models 33 | - Well-documented with docstrings 34 | - Includes unit tests 35 | - Uses async patterns consistently 36 | - Implements proper error handling 37 | 38 | ## Expected Functionality 39 | First phase should include: 40 | 1. List databases 41 | 2. Query database items 42 | 3. Create/update pages 43 | 5. Search functionality 44 | 45 | ## Specific Requirements 46 | - Use the official Notion Python SDK 47 | - Implement caching where appropriate 48 | - Add proper logging 49 | - Include progress notifications for long operations 50 | - Handle rate limiting 51 | 52 | Please help me implement this server step by step, starting with the basic structure and building up to a complete implementation. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "notion-mcp" 3 | version = "0.2.0" 4 | description = "MCP server for Notion integration" 5 | authors = [ 6 | {name = "Chase Cabanillas", email = "chase.cabanillas@gmail.com"} 7 | ] 8 | dependencies = [ 9 | "mcp>=0.2.0", 10 | "httpx>=0.27.0", 11 | "pydantic>=2.5.0", 12 | "python-dotenv>=1.0.0", 13 | "rich>=13.7.0" 14 | ] 15 | requires-python = ">=3.10" 16 | 17 | [build-system] 18 | requires = ["hatchling"] 19 | build-backend = "hatchling.build" 20 | 21 | [tool.rye] 22 | managed = true 23 | dev-dependencies = [ 24 | "pytest>=7.3.1", 25 | "pytest-asyncio>=0.21.0" 26 | ] 27 | 28 | [tool.pytest.ini_options] 29 | asyncio_mode = "auto" -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - notionApiKey 10 | properties: 11 | notionApiKey: 12 | type: string 13 | description: Notion Integration API Key obtained from 14 | https://www.notion.so/my-integrations 15 | description: Configuration for Notion MCP server 16 | commandFunction: 17 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 18 | |- 19 | (config) => ({ 20 | command: 'python', 21 | args: ['-m', 'notion_mcp'], 22 | env: { NOTION_API_KEY: config.notionApiKey } 23 | }) 24 | exampleConfig: 25 | notionApiKey: secret_example_notion_api_key_123 26 | -------------------------------------------------------------------------------- /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/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/__pycache__/__init__.cpython-311.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/__main__.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/__pycache__/__main__.cpython-311.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/__main__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/__pycache__/__main__.cpython-313.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/client.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/__pycache__/client.cpython-313.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/server.cpython-311.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/__pycache__/server.cpython-311.pyc -------------------------------------------------------------------------------- /src/notion_mcp/__pycache__/server.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/__pycache__/server.cpython-313.pyc -------------------------------------------------------------------------------- /src/notion_mcp/client.py: -------------------------------------------------------------------------------- 1 | """Notion API client implementation.""" 2 | 3 | import os 4 | from typing import Any, Dict, List, Optional, Union 5 | import httpx 6 | import logging 7 | import sys 8 | import rich 9 | from rich.logging import RichHandler 10 | 11 | from .models.notion import Database, Page, SearchResults, PropertyValue 12 | 13 | class NotionClient: 14 | """Client for interacting with the Notion API.""" 15 | 16 | def __init__(self, api_key: str): 17 | """Initialize the Notion client. 18 | 19 | Args: 20 | api_key: Notion API key 21 | """ 22 | self.api_key = api_key 23 | self.base_url = "https://api.notion.com/v1" 24 | # Updated to the latest stable Notion API version 25 | self.headers = { 26 | "Authorization": f"Bearer {api_key}", 27 | "Content-Type": "application/json", 28 | "Notion-Version": "2022-02-22" 29 | } 30 | 31 | # Set up logging to stderr to avoid breaking MCP 32 | self.logger = logging.getLogger("notion_client") 33 | self.logger.setLevel(logging.INFO) 34 | 35 | # Make sure handler outputs to stderr 36 | if not self.logger.handlers: 37 | handler = RichHandler(rich_tracebacks=True, console=rich.console.Console(file=sys.stderr)) 38 | self.logger.addHandler(handler) 39 | 40 | async def list_databases(self) -> List[Database]: 41 | """List all databases the integration has access to.""" 42 | try: 43 | async with httpx.AsyncClient() as client: 44 | response = await client.post( 45 | f"{self.base_url}/search", 46 | headers=self.headers, 47 | json={ 48 | "filter": { 49 | "property": "object", 50 | "value": "database" 51 | }, 52 | "page_size": 100, 53 | "sort": { 54 | "direction": "descending", 55 | "timestamp": "last_edited_time" 56 | } 57 | } 58 | ) 59 | response.raise_for_status() 60 | data = response.json() 61 | if not data.get("results"): 62 | return [] 63 | return [Database.model_validate(db) for db in data["results"]] 64 | except httpx.HTTPStatusError as e: 65 | self.logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") 66 | raise 67 | except Exception as e: 68 | self.logger.error(f"Error listing databases: {str(e)}") 69 | raise 70 | 71 | async def query_database( 72 | self, 73 | database_id: str, 74 | filter: Optional[Dict[str, Any]] = None, 75 | sorts: Optional[List[Dict[str, Any]]] = None, 76 | start_cursor: Optional[str] = None, 77 | page_size: int = 100 78 | ) -> Dict[str, Any]: 79 | """Query a database.""" 80 | try: 81 | body = { 82 | "page_size": page_size 83 | } 84 | if filter: 85 | body["filter"] = filter 86 | if sorts: 87 | body["sorts"] = sorts 88 | if start_cursor: 89 | body["start_cursor"] = start_cursor 90 | 91 | async with httpx.AsyncClient() as client: 92 | response = await client.post( 93 | f"{self.base_url}/databases/{database_id}/query", 94 | headers=self.headers, 95 | json=body 96 | ) 97 | response.raise_for_status() 98 | return response.json() 99 | except httpx.HTTPStatusError as e: 100 | self.logger.error(f"HTTP error querying database {database_id}: {e.response.status_code} - {e.response.text}") 101 | raise 102 | except Exception as e: 103 | self.logger.error(f"Error querying database {database_id}: {str(e)}") 104 | raise 105 | 106 | async def create_page( 107 | self, 108 | parent_id: str, 109 | properties: Dict[str, Any], 110 | children: Optional[List[Dict[str, Any]]] = None 111 | ) -> Page: 112 | """Create a new page.""" 113 | try: 114 | body = { 115 | "parent": {"database_id": parent_id}, 116 | "properties": properties 117 | } 118 | if children: 119 | body["children"] = children 120 | 121 | async with httpx.AsyncClient() as client: 122 | response = await client.post( 123 | f"{self.base_url}/pages", 124 | headers=self.headers, 125 | json=body 126 | ) 127 | response.raise_for_status() 128 | return Page.model_validate(response.json()) 129 | except httpx.HTTPStatusError as e: 130 | self.logger.error(f"HTTP error creating page: {e.response.status_code} - {e.response.text}") 131 | raise 132 | except Exception as e: 133 | self.logger.error(f"Error creating page: {str(e)}") 134 | raise 135 | 136 | async def update_page( 137 | self, 138 | page_id: str, 139 | properties: Dict[str, Any], 140 | archived: Optional[bool] = None 141 | ) -> Page: 142 | """Update a page.""" 143 | try: 144 | body = {"properties": properties} 145 | if archived is not None: 146 | body["archived"] = archived 147 | 148 | async with httpx.AsyncClient() as client: 149 | response = await client.patch( 150 | f"{self.base_url}/pages/{page_id}", 151 | headers=self.headers, 152 | json=body 153 | ) 154 | response.raise_for_status() 155 | return Page.model_validate(response.json()) 156 | except httpx.HTTPStatusError as e: 157 | self.logger.error(f"HTTP error updating page {page_id}: {e.response.status_code} - {e.response.text}") 158 | raise 159 | except Exception as e: 160 | self.logger.error(f"Error updating page {page_id}: {str(e)}") 161 | raise 162 | 163 | async def search( 164 | self, 165 | query: str = "", 166 | filter: Optional[Dict[str, Any]] = None, 167 | sort: Optional[Dict[str, Any]] = None, 168 | start_cursor: Optional[str] = None, 169 | page_size: int = 100 170 | ) -> SearchResults: 171 | """Search Notion.""" 172 | try: 173 | body = { 174 | "query": query, 175 | "page_size": page_size 176 | } 177 | if filter: 178 | body["filter"] = filter 179 | if sort: 180 | body["sort"] = sort 181 | if start_cursor: 182 | body["start_cursor"] = start_cursor 183 | 184 | async with httpx.AsyncClient() as client: 185 | response = await client.post( 186 | f"{self.base_url}/search", 187 | headers=self.headers, 188 | json=body 189 | ) 190 | response.raise_for_status() 191 | data = response.json() 192 | 193 | # Convert results based on their object type 194 | results = [] 195 | for item in data.get("results", []): 196 | try: 197 | if item["object"] == "database": 198 | results.append(Database.model_validate(item)) 199 | elif item["object"] == "page": 200 | results.append(Page.model_validate(item)) 201 | except Exception as e: 202 | self.logger.warning(f"Error processing search result: {str(e)}") 203 | continue 204 | 205 | return SearchResults( 206 | object="list", 207 | results=results, 208 | next_cursor=data.get("next_cursor"), 209 | has_more=data.get("has_more", False) 210 | ) 211 | except httpx.HTTPStatusError as e: 212 | self.logger.error(f"HTTP error during search: {e.response.status_code} - {e.response.text}") 213 | raise 214 | except Exception as e: 215 | self.logger.error(f"Error during search: {str(e)}") 216 | raise 217 | 218 | async def get_block_children( 219 | self, 220 | block_id: str, 221 | start_cursor: Optional[str] = None, 222 | page_size: int = 100 223 | ) -> Dict[str, Any]: 224 | """Get children blocks of a block.""" 225 | try: 226 | params = {"page_size": page_size} 227 | if start_cursor: 228 | params["start_cursor"] = start_cursor 229 | 230 | async with httpx.AsyncClient() as client: 231 | response = await client.get( 232 | f"{self.base_url}/blocks/{block_id}/children", 233 | headers=self.headers, 234 | params=params 235 | ) 236 | response.raise_for_status() 237 | return response.json() 238 | except httpx.HTTPStatusError as e: 239 | self.logger.error(f"HTTP error getting block children: {e.response.status_code} - {e.response.text}") 240 | raise 241 | except Exception as e: 242 | self.logger.error(f"Error getting block children: {str(e)}") 243 | raise 244 | 245 | async def get_database( 246 | self, 247 | database_id: str 248 | ) -> Database: 249 | """Get database metadata.""" 250 | try: 251 | async with httpx.AsyncClient() as client: 252 | response = await client.get( 253 | f"{self.base_url}/databases/{database_id}", 254 | headers=self.headers 255 | ) 256 | response.raise_for_status() 257 | return Database.model_validate(response.json()) 258 | except httpx.HTTPStatusError as e: 259 | self.logger.error(f"HTTP error getting database: {e.response.status_code} - {e.response.text}") 260 | raise 261 | except Exception as e: 262 | self.logger.error(f"Error getting database: {str(e)}") 263 | raise -------------------------------------------------------------------------------- /src/notion_mcp/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Notion MCP models package.""" -------------------------------------------------------------------------------- /src/notion_mcp/models/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/models/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /src/notion_mcp/models/__pycache__/notion.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccabanillas/notion-mcp/6871e6e1190dc8f50d09180fa3e71410f592c77f/src/notion_mcp/models/__pycache__/notion.cpython-313.pyc -------------------------------------------------------------------------------- /src/notion_mcp/models/notion.py: -------------------------------------------------------------------------------- 1 | """Pydantic models for Notion API objects.""" 2 | 3 | from typing import Any, Dict, List, Optional, Union, Literal 4 | from pydantic import BaseModel, Field, ConfigDict 5 | from datetime import datetime 6 | 7 | class NotionObject(BaseModel): 8 | """Base class for Notion objects.""" 9 | model_config = ConfigDict(extra="ignore") 10 | 11 | object: str 12 | id: str 13 | created_time: datetime 14 | last_edited_time: Optional[datetime] = None 15 | url: Optional[str] = None 16 | public_url: Optional[str] = None 17 | 18 | class RichText(BaseModel): 19 | """Model for rich text content.""" 20 | model_config = ConfigDict(extra="ignore") 21 | 22 | type: str 23 | text: Dict[str, Any] = Field(default_factory=dict) 24 | annotations: Optional[Dict[str, Any]] = None 25 | plain_text: Optional[str] = None 26 | href: Optional[str] = None 27 | 28 | class PropertyValue(BaseModel): 29 | """Model for property values.""" 30 | model_config = ConfigDict(extra="ignore") 31 | 32 | id: str 33 | type: str 34 | title: Optional[List[RichText]] = None 35 | rich_text: Optional[List[RichText]] = None 36 | select: Optional[Dict[str, Any]] = None 37 | multi_select: Optional[List[Dict[str, Any]]] = None 38 | url: Optional[str] = None 39 | checkbox: Optional[bool] = None 40 | number: Optional[float] = None 41 | date: Optional[Dict[str, Any]] = None 42 | email: Optional[str] = None 43 | phone_number: Optional[str] = None 44 | formula: Optional[Dict[str, Any]] = None 45 | relation: Optional[List[Dict[str, str]]] = None 46 | rollup: Optional[Dict[str, Any]] = None 47 | created_time: Optional[datetime] = None 48 | created_by: Optional[Dict[str, Any]] = None 49 | last_edited_time: Optional[datetime] = None 50 | last_edited_by: Optional[Dict[str, Any]] = None 51 | files: Optional[List[Dict[str, Any]]] = None 52 | status: Optional[Dict[str, Any]] = None 53 | 54 | class Page(NotionObject): 55 | """Model for a Notion page.""" 56 | parent: Dict[str, Any] 57 | archived: bool = False 58 | properties: Dict[str, Any] 59 | 60 | def model_post_init(self, __context): 61 | """Process properties after initialization""" 62 | processed_properties = {} 63 | for key, value in self.properties.items(): 64 | if isinstance(value, dict) and 'type' in value: 65 | try: 66 | processed_properties[key] = PropertyValue.model_validate(value) 67 | except Exception: 68 | # Keep the original value if validation fails 69 | processed_properties[key] = value 70 | else: 71 | processed_properties[key] = value 72 | self.properties = processed_properties 73 | 74 | class DatabaseProperty(BaseModel): 75 | """Model for database property configuration.""" 76 | model_config = ConfigDict(extra="ignore") 77 | 78 | id: str 79 | name: str 80 | type: str 81 | 82 | # Type-specific configurations 83 | number: Optional[Dict[str, Any]] = None 84 | select: Optional[Dict[str, Any]] = None 85 | multi_select: Optional[Dict[str, Any]] = None 86 | status: Optional[Dict[str, Any]] = None 87 | formula: Optional[Dict[str, Any]] = None 88 | relation: Optional[Dict[str, Any]] = None 89 | rollup: Optional[Dict[str, Any]] = None 90 | 91 | class Database(NotionObject): 92 | """Model for a Notion database.""" 93 | title: List[RichText] 94 | description: List[RichText] = Field(default_factory=list) 95 | properties: Dict[str, Any] 96 | archived: bool = False 97 | 98 | def model_post_init(self, __context): 99 | """Process properties after initialization""" 100 | processed_properties = {} 101 | for key, value in self.properties.items(): 102 | if isinstance(value, dict) and 'type' in value: 103 | try: 104 | processed_properties[key] = DatabaseProperty.model_validate(value) 105 | except Exception: 106 | # Keep the original value if validation fails 107 | processed_properties[key] = value 108 | else: 109 | processed_properties[key] = value 110 | self.properties = processed_properties 111 | 112 | class Block(NotionObject): 113 | """Model for a Notion block.""" 114 | type: str 115 | has_children: bool = False 116 | archived: bool = False 117 | content: Dict[str, Any] = Field(default_factory=dict) 118 | 119 | def model_post_init(self, __context): 120 | """Process block content based on type""" 121 | if self.type in self.__dict__ and isinstance(self.__dict__[self.type], dict): 122 | self.content = self.__dict__[self.type] 123 | 124 | class SearchResults(BaseModel): 125 | """Model for search results.""" 126 | model_config = ConfigDict(extra="ignore") 127 | 128 | object: str = "list" 129 | results: List[Any] 130 | next_cursor: Optional[str] = None 131 | has_more: bool = False -------------------------------------------------------------------------------- /src/notion_mcp/server.py: -------------------------------------------------------------------------------- 1 | """MCP server implementation for Notion integration.""" 2 | 3 | from mcp.server import Server 4 | from mcp.server.stdio import stdio_server 5 | from mcp.types import Resource, Tool, TextContent, EmbeddedResource 6 | from typing import Any, Dict, List, Optional, Sequence, Union 7 | import os 8 | import json 9 | from datetime import datetime 10 | import logging 11 | from pathlib import Path 12 | from dotenv import load_dotenv 13 | import rich 14 | from rich.logging import RichHandler 15 | 16 | from .client import NotionClient 17 | from .models.notion import Database, Page, SearchResults, Block 18 | 19 | # Set up enhanced logging with rich - directing logs to stderr to avoid breaking MCP 20 | import sys 21 | logging.basicConfig( 22 | level=logging.INFO, 23 | format="%(message)s", 24 | datefmt="[%X]", 25 | handlers=[RichHandler(rich_tracebacks=True, console=rich.console.Console(file=sys.stderr))] 26 | ) 27 | logger = logging.getLogger('notion_mcp') 28 | 29 | # Find and load .env file from project root 30 | project_root = Path(__file__).parent.parent.parent 31 | env_path = project_root / '.env' 32 | if env_path.exists(): 33 | load_dotenv(env_path) 34 | 35 | # Configuration with validation 36 | NOTION_API_KEY = os.getenv("NOTION_API_KEY") 37 | if not NOTION_API_KEY: 38 | raise ValueError("NOTION_API_KEY not found in .env file") 39 | 40 | # Initialize server with name only (MCP 1.6.0 compatible) 41 | server = Server("notion-mcp") 42 | 43 | # Initialize Notion client 44 | notion_client = NotionClient(NOTION_API_KEY) 45 | 46 | # Roots functionality not supported in MCP 1.6.0 47 | # Leaving a comment to indicate future enhancement when MCP is upgraded 48 | 49 | @server.list_tools() 50 | async def list_tools() -> List[Tool]: 51 | """List available Notion tools.""" 52 | return [ 53 | Tool( 54 | name="list_databases", 55 | description="List all accessible Notion databases", 56 | inputSchema={ 57 | "type": "object", 58 | "properties": {}, 59 | "required": [] 60 | } 61 | ), 62 | Tool( 63 | name="get_database", 64 | description="Get details about a specific Notion database", 65 | inputSchema={ 66 | "type": "object", 67 | "properties": { 68 | "database_id": { 69 | "type": "string", 70 | "description": "ID of the database to retrieve" 71 | } 72 | }, 73 | "required": ["database_id"] 74 | } 75 | ), 76 | Tool( 77 | name="query_database", 78 | description="Query items from a Notion database", 79 | inputSchema={ 80 | "type": "object", 81 | "properties": { 82 | "database_id": { 83 | "type": "string", 84 | "description": "ID of the database to query" 85 | }, 86 | "filter": { 87 | "type": "object", 88 | "description": "Optional filter criteria" 89 | }, 90 | "sorts": { 91 | "type": "array", 92 | "description": "Optional sort criteria" 93 | }, 94 | "start_cursor": { 95 | "type": "string", 96 | "description": "Cursor for pagination" 97 | }, 98 | "page_size": { 99 | "type": "integer", 100 | "description": "Number of results per page", 101 | "default": 100 102 | } 103 | }, 104 | "required": ["database_id"] 105 | } 106 | ), 107 | Tool( 108 | name="create_page", 109 | description="Create a new page in a database", 110 | inputSchema={ 111 | "type": "object", 112 | "properties": { 113 | "database_id": { 114 | "type": "string", 115 | "description": "ID of the database to create the page in" 116 | }, 117 | "properties": { 118 | "type": "object", 119 | "description": "Page properties matching the database schema" 120 | }, 121 | "children": { 122 | "type": "array", 123 | "description": "Optional page content blocks" 124 | } 125 | }, 126 | "required": ["database_id", "properties"] 127 | } 128 | ), 129 | Tool( 130 | name="update_page", 131 | description="Update an existing page", 132 | inputSchema={ 133 | "type": "object", 134 | "properties": { 135 | "page_id": { 136 | "type": "string", 137 | "description": "ID of the page to update" 138 | }, 139 | "properties": { 140 | "type": "object", 141 | "description": "Updated page properties" 142 | }, 143 | "archived": { 144 | "type": "boolean", 145 | "description": "Whether to archive the page" 146 | } 147 | }, 148 | "required": ["page_id", "properties"] 149 | } 150 | ), 151 | Tool( 152 | name="get_block_children", 153 | description="Get the children blocks of a block", 154 | inputSchema={ 155 | "type": "object", 156 | "properties": { 157 | "block_id": { 158 | "type": "string", 159 | "description": "ID of the block to get children for" 160 | }, 161 | "start_cursor": { 162 | "type": "string", 163 | "description": "Cursor for pagination" 164 | }, 165 | "page_size": { 166 | "type": "integer", 167 | "description": "Number of results per page", 168 | "default": 100 169 | } 170 | }, 171 | "required": ["block_id"] 172 | } 173 | ), 174 | Tool( 175 | name="search", 176 | description="Search Notion content", 177 | inputSchema={ 178 | "type": "object", 179 | "properties": { 180 | "query": { 181 | "type": "string", 182 | "description": "Search query string" 183 | }, 184 | "filter": { 185 | "type": "object", 186 | "description": "Filter criteria for search results" 187 | }, 188 | "sort": { 189 | "type": "object", 190 | "description": "Sort criteria for search results" 191 | }, 192 | "start_cursor": { 193 | "type": "string", 194 | "description": "Cursor for pagination" 195 | }, 196 | "page_size": { 197 | "type": "integer", 198 | "description": "Number of results per page", 199 | "default": 100 200 | } 201 | }, 202 | "required": [] 203 | } 204 | ) 205 | ] 206 | 207 | # Resources functionality not supported in MCP 1.6.0 208 | # Leaving a comment to indicate future enhancement when MCP is upgraded 209 | 210 | @server.call_tool() 211 | async def call_tool(name: str, arguments: Any) -> Sequence[Union[TextContent, EmbeddedResource]]: 212 | """Handle tool calls for Notion operations.""" 213 | try: 214 | if name == "list_databases": 215 | databases = await notion_client.list_databases() 216 | return [ 217 | TextContent( 218 | type="text", 219 | text=json.dumps({ 220 | "databases": [db.model_dump() for db in databases] 221 | }, indent=2, default=str) 222 | ) 223 | ] 224 | 225 | elif name == "get_database": 226 | if not isinstance(arguments, dict): 227 | raise ValueError("Invalid arguments") 228 | 229 | database_id = arguments.get("database_id") 230 | if not database_id: 231 | raise ValueError("database_id is required") 232 | 233 | database = await notion_client.get_database(database_id) 234 | return [ 235 | TextContent( 236 | type="text", 237 | text=database.model_dump_json(indent=2) 238 | ) 239 | ] 240 | 241 | elif name == "query_database": 242 | if not isinstance(arguments, dict): 243 | raise ValueError("Invalid arguments") 244 | 245 | database_id = arguments.get("database_id") 246 | if not database_id: 247 | raise ValueError("database_id is required") 248 | 249 | results = await notion_client.query_database( 250 | database_id=database_id, 251 | filter=arguments.get("filter"), 252 | sorts=arguments.get("sorts"), 253 | start_cursor=arguments.get("start_cursor"), 254 | page_size=arguments.get("page_size", 100) 255 | ) 256 | return [ 257 | TextContent( 258 | type="text", 259 | text=json.dumps(results, indent=2, default=str) 260 | ) 261 | ] 262 | 263 | elif name == "create_page": 264 | if not isinstance(arguments, dict): 265 | raise ValueError("Invalid arguments") 266 | 267 | database_id = arguments.get("database_id") 268 | properties = arguments.get("properties") 269 | if not database_id or not properties: 270 | raise ValueError("database_id and properties are required") 271 | 272 | page = await notion_client.create_page( 273 | parent_id=database_id, 274 | properties=properties, 275 | children=arguments.get("children") 276 | ) 277 | return [ 278 | TextContent( 279 | type="text", 280 | text=page.model_dump_json(indent=2) 281 | ) 282 | ] 283 | 284 | elif name == "update_page": 285 | if not isinstance(arguments, dict): 286 | raise ValueError("Invalid arguments") 287 | 288 | page_id = arguments.get("page_id") 289 | properties = arguments.get("properties") 290 | if not page_id or not properties: 291 | raise ValueError("page_id and properties are required") 292 | 293 | page = await notion_client.update_page( 294 | page_id=page_id, 295 | properties=properties, 296 | archived=arguments.get("archived") 297 | ) 298 | return [ 299 | TextContent( 300 | type="text", 301 | text=page.model_dump_json(indent=2) 302 | ) 303 | ] 304 | 305 | elif name == "get_block_children": 306 | if not isinstance(arguments, dict): 307 | raise ValueError("Invalid arguments") 308 | 309 | block_id = arguments.get("block_id") 310 | if not block_id: 311 | raise ValueError("block_id is required") 312 | 313 | results = await notion_client.get_block_children( 314 | block_id=block_id, 315 | start_cursor=arguments.get("start_cursor"), 316 | page_size=arguments.get("page_size", 100) 317 | ) 318 | return [ 319 | TextContent( 320 | type="text", 321 | text=json.dumps(results, indent=2, default=str) 322 | ) 323 | ] 324 | 325 | elif name == "search": 326 | if not isinstance(arguments, dict): 327 | raise ValueError("Invalid arguments") 328 | 329 | query = arguments.get("query", "") 330 | results = await notion_client.search( 331 | query=query, 332 | filter=arguments.get("filter"), 333 | sort=arguments.get("sort"), 334 | start_cursor=arguments.get("start_cursor"), 335 | page_size=arguments.get("page_size", 100) 336 | ) 337 | return [ 338 | TextContent( 339 | type="text", 340 | text=results.model_dump_json(indent=2) 341 | ) 342 | ] 343 | 344 | else: 345 | raise ValueError(f"Unknown tool: {name}") 346 | 347 | except Exception as e: 348 | logger.error(f"Error in tool {name}: {str(e)}") 349 | return [ 350 | TextContent( 351 | type="text", 352 | text=f"Error: {str(e)}" 353 | ) 354 | ] 355 | 356 | async def main(): 357 | """Run the server.""" 358 | if not NOTION_API_KEY: 359 | raise ValueError("NOTION_API_KEY environment variable is required") 360 | 361 | logger.info("Starting Notion MCP Server v0.2.0") 362 | logger.info(f"Using Notion API with key: {NOTION_API_KEY[:4]}...") 363 | 364 | try: 365 | # Test connection to Notion API 366 | databases = await notion_client.list_databases() 367 | logger.info(f"Successfully connected to Notion API. Found {len(databases)} accessible databases.") 368 | except Exception as e: 369 | logger.error(f"Failed to connect to Notion API: {str(e)}") 370 | raise 371 | 372 | async with stdio_server() as (read_stream, write_stream): 373 | await server.run( 374 | read_stream, 375 | write_stream, 376 | server.create_initialization_options() 377 | ) 378 | 379 | if __name__ == "__main__": 380 | import asyncio 381 | asyncio.run(main()) --------------------------------------------------------------------------------