# Directory Structure ``` ├── .env.example ├── .gitignore ├── .npmignore ├── package-lock.json ├── package.json ├── README.md └── server.js ``` # Files -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` .aider* .env ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` # Notion API Key NOTION_API_KEY=your_notion_api_key_here ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` .aider* .env # Dependency directories node_modules/ # Environment variables .env # Logs logs *.log npm-debug.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # Mac files .DS_Store ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Notion MCP Server A Model Context Protocol (MCP) server that connects Claude and other AI assistants to your Notion workspace. This integration allows AI assistants to interact with your Notion databases, pages, and blocks. ## What is this? This tool acts as a bridge between AI assistants (like Claude) and your Notion workspace. It allows the AI to: - View and search your Notion databases - Create and update pages - Manage content blocks - And much more! ## Step-by-Step Setup Guide ### Prerequisites - [Node.js](https://nodejs.org/) (version 14 or higher) - A Notion account - Claude Desktop app (if using with Claude) ### 1. Getting Your Notion API Key 1. Go to [https://www.notion.so/my-integrations](https://www.notion.so/my-integrations) 2. Click the blue **"+ New integration"** button 3. Fill in the details: - **Name**: Choose a name like "Claude Assistant" or "AI Helper" - **Logo**: Optional - **Associated workspace**: Select your Notion workspace 4. Click **"Submit"** 5. On the next page, find the **"Internal Integration Token"** section 6. Click **"Show"** and copy the token (it starts with `secret_`) ## 2. Setting Up This Server ### Download the Repository **Option A: Download as ZIP (Recommended for beginners)** 1. Go to the GitHub repository: https://github.com/Sjotie/notionMCP/ 2. Click the green "Code" button at the top right 3. Select "Download ZIP" 4. Once downloaded, extract the ZIP file to a location on your computer - Windows: Right-click the ZIP file and select "Extract All" - Mac: Double-click the ZIP file to extract **Option B: Clone with Git (For users familiar with Git)** 1. Open a command prompt or terminal - Windows: Press `Win+R`, type `cmd`, and press Enter - Mac: Open Terminal from Applications > Utilities 2. Navigate to where you want to store the repository ``` cd path/to/desired/location ``` 3. Clone the repository ``` git clone https://github.com/Sjotie/notionMCP/ ``` ### Navigate to the Project Directory After downloading or cloning, you need to navigate to the project folder using the `cd` (change directory) command: **If you downloaded the ZIP (Option A):** 1. Open a command prompt or terminal 2. Use the `cd` command to navigate to where you extracted the ZIP file: ``` cd path/to/extracted/folder/notionMCP ``` For example: - On Windows: `cd C:\Users\YourName\Downloads\notionMCP` - On Mac: `cd /Users/YourName/Downloads/notionMCP` **If you cloned with Git (Option B):** 1. The repository should have been cloned into a folder named "notionMCP" 2. If you're still in the same terminal window after cloning, simply type: ``` cd notionMCP ``` **How to know you're in the right directory:** - After using the `cd` command, you can check your current location: - On Windows: Type `dir` and press Enter - you should see files like `server.js` - On Mac: Type `ls` and press Enter - you should see files like `server.js` ### Install Dependencies Once you're in the notionMCP directory, install the required dependencies: ``` npm install ``` This will install all the necessary Node.js packages. You should see a progress bar and eventually a message indicating the installation is finished. It might say something along the lines of "X Packages are looking for funding" - this is completely normal and means it worked. ### 3. Connecting to Notion Pages For security, Notion requires you to explicitly grant access to each page or database: 1. Open Notion and navigate to a page or database you want the AI to access 2. Click the **"•••"** (three dots) in the top-right corner 3. Select **"Add connections"** 4. Find and select the integration you created earlier 5. Repeat for any other pages or databases you want to make accessible ### 4. Connecting to Claude Desktop 1. Locate your Claude Desktop configuration file: - Windows: `%APPDATA%\Claude\claude_desktop_config.json` (Type this path in File Explorer address bar) - Mac: `~/Library/Application Support/Claude/claude_desktop_config.json` (In Finder, press Cmd+Shift+G and paste this path) 2. Open the file in a text editor. If it doesn't exist, create it with the following content: ```json { "mcpServers": { "notion": { "command": "node", "args": [ "C:\\path\\to\\notion-mcp-server\\server.js" ], "env": { "NOTION_API_KEY": "your_notion_api_key_here" } } } } ``` 3. Replace: - `C:\\path\\to\\notion-mcp-server\\server.js` with the actual path to the server.js file - Windows: Use double backslashes (\\\\) in the path - Mac: Use forward slashes (/) - `your_notion_api_key_here` with your Notion API key 4. Save the file and restart Claude Desktop ### 5. Testing the Connection 1. Start a new conversation in Claude 2. Ask Claude to interact with your Notion workspace, for example: - "Show me a list of my Notion databases" - "Create a new page in my Tasks database with title 'Test Task'" ## Available Tools The server provides these tools to AI assistants: - **list-databases**: View all accessible databases - **query-database**: Get entries from a database - **create-page**: Add a new page to a database - **update-page**: Modify an existing page - **create-database**: Create a new database - **update-database**: Modify a database structure - **get-page**: View a specific page - **get-block-children**: View content blocks - **append-block-children**: Add content to a page - **update-block**: Edit content blocks - **get-block**: View a specific block - **search**: Find content across your workspace ## Troubleshooting ### Common Issues: 1. **"Connection failed" in Claude** - Make sure the server path in claude_desktop_config.json is correct - Check that your Notion API key is valid - Ensure Node.js is installed 2. **"Access denied" when accessing Notion content** - Make sure you've shared the page/database with your integration - Check that your API key has the necessary permissions 3. **Server won't start** - Ensure all dependencies are installed (`npm install`) - Check that the .env file exists with your API key ### Getting Help If you encounter issues not covered here, please: - Check the console output for error messages - Ensure your Notion API key is valid - Verify that your integration has access to the pages/databases ## License MIT ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@sjotie/notion-mcp-server", "version": "1.0.3", "description": "MCP server for Notion integration", "type": "module", "main": "server.js", "scripts": { "start": "node server.js", "dev": "nodemon server.js" }, "keywords": [ "notion", "mcp", "model-context-protocol" ], "author": "Sjotie", "license": "MIT", "bin": { "notion-mcp-server": "./server.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^0.7.0", "@notionhq/client": "^2.2.16", "dotenv": "^16.4.7", "zod": "^3.24.2" }, "devDependencies": { "nodemon": "^3.0.2" }, "repository": { "type": "git", "url": "git+https://github.com/Sjotie/notionMCP.git" }, "bugs": { "url": "https://github.com/Sjotie/notionMCP/issues" }, "homepage": "https://github.com/Sjotie/notionMCP#readme" } ``` -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { Client } from "@notionhq/client"; import dotenv from "dotenv"; // Load environment variables dotenv.config(); // Initialize Notion client const notion = new Client({ auth: process.env.NOTION_API_KEY, }); // Create MCP server const server = new Server({ name: "notion-mcp", version: "1.0.0", }, { capabilities: { tools: {} } }); // Add a request interceptor for debugging server.setRequestHandler(z.object({ method: z.string(), params: z.any().optional() }), async (request) => { console.error("Received request:", JSON.stringify(request, null, 2)); // Let the request continue to be handled by other handlers return undefined; }, { priority: -1 }); // List databases tool server.setRequestHandler(z.object({ method: z.literal("tools/list") }), async () => { return { tools: [ { name: "list-databases", description: "List all databases the integration has access to", inputSchema: { type: "object", properties: {} } }, { name: "query-database", description: "Query a database", inputSchema: { type: "object", properties: { database_id: { type: "string", description: "ID of the database to query" }, filter: { type: "object", description: "Optional filter criteria" }, sorts: { type: "array", description: "Optional sort criteria" }, start_cursor: { type: "string", description: "Optional cursor for pagination" }, page_size: { type: "number", description: "Number of results per page", default: 100 } }, required: ["database_id"] } }, { name: "create-page", description: "Create a new page in a database", inputSchema: { type: "object", properties: { parent_id: { type: "string", description: "ID of the parent database" }, properties: { type: "object", description: "Page properties" }, children: { type: "array", description: "Optional content blocks" } }, required: ["parent_id", "properties"] } }, { name: "update-page", description: "Update an existing page", inputSchema: { type: "object", properties: { page_id: { type: "string", description: "ID of the page to update" }, properties: { type: "object", description: "Updated page properties" }, archived: { type: "boolean", description: "Whether to archive the page" } }, required: ["page_id", "properties"] } }, { name: "create-database", description: "Create a new database", inputSchema: { type: "object", properties: { parent_id: { type: "string", description: "ID of the parent page" }, title: { type: "array", description: "Database title as rich text array" }, properties: { type: "object", description: "Database properties schema" }, icon: { type: "object", description: "Optional icon for the database" }, cover: { type: "object", description: "Optional cover for the database" } }, required: ["parent_id", "title", "properties"] } }, { name: "update-database", description: "Update an existing database", inputSchema: { type: "object", properties: { database_id: { type: "string", description: "ID of the database to update" }, title: { type: "array", description: "Optional new title as rich text array" }, description: { type: "array", description: "Optional new description as rich text array" }, properties: { type: "object", description: "Optional updated properties schema" } }, required: ["database_id"] } }, { name: "get-page", description: "Retrieve a page by its ID", inputSchema: { type: "object", properties: { page_id: { type: "string", description: "ID of the page to retrieve" } }, required: ["page_id"] } }, { name: "get-block-children", description: "Retrieve the children blocks of a block", inputSchema: { type: "object", properties: { block_id: { type: "string", description: "ID of the block (page or block)" }, start_cursor: { type: "string", description: "Cursor for pagination" }, page_size: { type: "number", description: "Number of results per page", default: 100 } }, required: ["block_id"] } }, { name: "append-block-children", description: "Append blocks to a parent block", inputSchema: { type: "object", properties: { block_id: { type: "string", description: "ID of the parent block (page or block)" }, children: { type: "array", description: "List of block objects to append" }, after: { type: "string", description: "Optional ID of an existing block to append after" } }, required: ["block_id", "children"] } }, { name: "update-block", description: "Update a block's content or archive status", inputSchema: { type: "object", properties: { block_id: { type: "string", description: "ID of the block to update" }, block_type: { type: "string", description: "The type of block (paragraph, heading_1, to_do, etc.)" }, content: { type: "object", description: "The content for the block based on its type" }, archived: { type: "boolean", description: "Whether to archive (true) or restore (false) the block" } }, required: ["block_id", "block_type", "content"] } }, { name: "get-block", description: "Retrieve a block by its ID", inputSchema: { type: "object", properties: { block_id: { type: "string", description: "ID of the block to retrieve" } }, required: ["block_id"] } }, { name: "search", description: "Search Notion for pages or databases", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query string", default: "" }, filter: { type: "object", description: "Optional filter criteria" }, sort: { type: "object", description: "Optional sort criteria" }, start_cursor: { type: "string", description: "Cursor for pagination" }, page_size: { type: "number", description: "Number of results per page", default: 100 } } } } ] }; }); // Define a single CallToolRequestSchema handler for all tools server.setRequestHandler(z.object({ method: z.literal("tools/call"), params: z.object({ name: z.string(), arguments: z.any() }) }), async (request) => { const { name, arguments: args } = request.params; try { // Handle each tool based on name if (name === "list-databases") { const response = await notion.search({ filter: { property: "object", value: "database", }, page_size: 100, sort: { direction: "descending", timestamp: "last_edited_time", }, }); return { content: [ { type: "text", text: JSON.stringify(response.results, null, 2), }, ], }; } else if (name === "query-database") { console.error("Query database handler called with:", JSON.stringify(args, null, 2)); const { database_id, filter, sorts, start_cursor, page_size } = args; const queryParams = { database_id, page_size: page_size || 100, }; if (filter) queryParams.filter = filter; if (sorts) queryParams.sorts = sorts; if (start_cursor) queryParams.start_cursor = start_cursor; const response = await notion.databases.query(queryParams); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "create-page") { const { parent_id, properties, children } = args; const pageParams = { parent: { database_id: parent_id }, properties, }; if (children) { pageParams.children = children; } const response = await notion.pages.create(pageParams); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "update-page") { const { page_id, properties, archived } = args; const updateParams = { page_id, properties, }; if (archived !== undefined) { updateParams.archived = archived; } const response = await notion.pages.update(updateParams); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "create-database") { let { parent_id, title, properties, icon, cover } = args; // Remove dashes if present in parent_id parent_id = parent_id.replace(/-/g, ""); const databaseParams = { parent: { type: "page_id", page_id: parent_id, }, title, properties, }; // Set default emoji if icon is specified but emoji is empty if (icon && icon.type === "emoji" && !icon.emoji) { icon.emoji = "📄"; // Default document emoji databaseParams.icon = icon; } else if (icon) { databaseParams.icon = icon; } if (cover) { databaseParams.cover = cover; } const response = await notion.databases.create(databaseParams); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "update-database") { const { database_id, title, description, properties } = args; const updateParams = { database_id, }; if (title !== undefined) { updateParams.title = title; } if (description !== undefined) { updateParams.description = description; } if (properties !== undefined) { updateParams.properties = properties; } const response = await notion.databases.update(updateParams); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "get-page") { let { page_id } = args; // Remove dashes if present in page_id page_id = page_id.replace(/-/g, ""); const response = await notion.pages.retrieve({ page_id }); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "get-block-children") { let { block_id, start_cursor, page_size } = args; // Remove dashes if present in block_id block_id = block_id.replace(/-/g, ""); const params = { block_id, page_size: page_size || 100, }; if (start_cursor) { params.start_cursor = start_cursor; } const response = await notion.blocks.children.list(params); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "append-block-children") { let { block_id, children, after } = args; // Remove dashes if present in block_id block_id = block_id.replace(/-/g, ""); const params = { block_id, children, }; if (after) { params.after = after.replace(/-/g, ""); // Ensure after ID is properly formatted } const response = await notion.blocks.children.append(params); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "update-block") { let { block_id, block_type, content, archived } = args; // Remove dashes if present in block_id block_id = block_id.replace(/-/g, ""); const updateParams = { block_id, [block_type]: content, }; if (archived !== undefined) { updateParams.archived = archived; } const response = await notion.blocks.update(updateParams); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "get-block") { let { block_id } = args; // Remove dashes if present in block_id block_id = block_id.replace(/-/g, ""); const response = await notion.blocks.retrieve({ block_id }); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } else if (name === "search") { const { query, filter, sort, start_cursor, page_size } = args; const searchParams = { query: query || "", page_size: page_size || 100, }; if (filter) { searchParams.filter = filter; } if (sort) { searchParams.sort = sort; } if (start_cursor) { searchParams.start_cursor = start_cursor; } const response = await notion.search(searchParams); return { content: [ { type: "text", text: JSON.stringify(response, null, 2), }, ], }; } // If we get here, the tool name wasn't recognized return { isError: true, content: [ { type: "text", text: `Unknown tool: ${name}`, }, ], }; } catch (error) { return { isError: true, content: [ { type: "text", text: `Error executing ${name}: ${error.message}`, }, ], }; } }); // Start the server async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Notion MCP Server running on stdio"); } // Add error handling for unhandled rejections process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at:', promise, 'reason:', reason); }); main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); }); ```