snickery

notion-mcp

Community snickery
Updated

MCP server for the Notion API: pages, databases, views, native markdown, comments, and a full file pipeline with in-place PDF reading

notion-mcp

An MCP server for the Notion API:pages, databases (2025-09+ data-source model handled transparently),native markdown read/write, comments, users, and a full filepipeline — upload/replace/import/download with positional inserts,page icons/covers, multi-part upload for large files, and in-place PDFreading with optional OCR. Built on thePython MCP SDK(FastMCP); runs as a local stdio server or a containerized StreamableHTTP service with bearer auth. 44 tools.

Unofficial project, not affiliated with or endorsed by Notion.

Table of Contents

  • Quick Start
  • Tool Reference
    • Search & Discovery
    • Reading Pages
    • Creating & Writing Pages
    • Editing Page Content
    • Page Lifecycle
    • Block Operations
    • Database Schema
    • Database Queries
    • Database Rows & Files
    • File Downloads
    • PDF Reading & OCR
    • Comments
    • Users
  • Markdown Format
  • Working with Databases
  • Working with Files
  • Common Workflows
  • Configuration
  • Architecture
  • Development
  • Testing
  • Local stdio Mode
  • Container Deployment
  • MCP Client Registration
  • Notion API Version Notes

Quick Start

# Prerequisites: Python 3.12+, uv
uv sync

# Set your Notion integration token
export NOTION_TOKEN=ntn_...

# Run the server
uv run notion-mcp
# Server starts on http://0.0.0.0:8322/mcp

Create a Notion internal integration and share the pages/databases you want to access with it.

Tool Reference

Search & Discovery

notion_search_pages

Search for pages the integration can access.

Parameter Type Required Default Description
query string yes Text to search for
limit int no 10 Max results (1-100)
in_trash bool no false Search trashed pages instead of active ones (2026-07 API addition; restore hits with notion_restore_page)

Returns page titles, IDs, and URLs. Use the page ID with any tool that accepts page_id.

Notion API: POST /search with filter.value = "page"

notion_search_databases

Search for databases the integration can access. Returns schema summaries.

Parameter Type Required Default Description
query string yes Text to search for
limit int no 10 Max results

Returns database_id, data_source_id, property names and types. Use the database_id with database tools.

Notion API: POST /search with filter.value = "data_source" (changed from "database" in API 2025-09-03)

notion_list_files_on_page

List all file/image/pdf/video/audio blocks on a page.

Parameter Type Required Description
page_id string yes The page to scan

Returns block IDs, filenames, block types, and source types. Use the block_id with notion_download_file.

Notion API: GET /blocks/{id}/children, filtered to file-type blocks

notion_list_local_files

List files in either the server's local file store (/data/files inside the container) or the cross-MCP shared mount (/shared).

Parameter Type Required Default Description
filter string no "" Substring filter on filename
location string no "files" "files" (the notion-mcp private store, FILES_DIR) or "shared" (the cross-MCP mount, SHARED_DIR, populated by google-accounts-mcp download_attachment(destination='shared'))
notion_purge_shared_files

Delete files from the cross-MCP shared mount (SHARED_DIR, /shared in the container) whose modification time is older than max_age_hours. Pair with a host-side cron/timer TTL sweep if you want automatic cleanup; this tool is mainly for agents that want to clean up proactively at the end of a workflow.

Parameter Type Required Default Description
max_age_hours float no 24.0 Minimum file age before deletion. Must be ≥ 0.
dry_run bool no False List victims without deleting

Regular files only — subdirectories are left alone on principle.

Reading Pages

There are two ways to read page content, optimized for different use cases.

notion_read_page

Read page content by fetching blocks and rendering them as markdown. Best for structured reading with optional block-level targeting.

Parameter Type Required Default Description
page_id string yes The page to read
max_depth int no 3 Recursion depth for nested blocks (1-5)
include_block_ids bool no false Suffix each line with <!-- block:UUID -->

When include_block_ids is true, each rendered line includes the block's UUID in an HTML comment. Use these IDs with notion_update_block or notion_delete_block for surgical edits.

Notion API: GET /blocks/{id}/children (recursive). Blocks are converted to markdown by blocks.py.

notion_read_page_markdown

Read page content using Notion's native markdown API. Faster (single API call) and returns Notion-flavored markdown including special tags for embedded databases, files, and page links.

Parameter Type Required Description
page_id string yes The page to read

The output includes Notion-specific tags like <database url="...">, <file src="...">, and <page url="..."> that aren't standard markdown but are useful for understanding page structure.

Notion API: GET /pages/{id}/markdown (API 2025-09-03+)

When to use which:

  • notion_read_page — when you need block IDs for editing, or want clean standard markdown
  • notion_read_page_markdown — when you need speed, or want to see Notion-native structure (databases, synced blocks, etc.)

Creating & Writing Pages

notion_create_page

Create a new page with optional markdown body content.

Parameter Type Required Default Description
title string yes Page title
content string no "" Markdown body content
parent_page_id string no "" Create as child of this page
database_id string no "" Create as row in this database
properties_json string no "" JSON object of Notion property values
icon string no "" Emoji for page icon (e.g. "🚀")

Provide either parent_page_id (standalone page) or database_id (database row). The title property name is auto-detected from the database schema.

For database rows, pass additional properties via properties_json using Notion property value format:

{
  "Status": {"select": {"name": "In Progress"}},
  "Priority": {"number": 5},
  "Due": {"date": {"start": "2026-03-15"}}
}

The content parameter accepts markdown which is parsed into Notion blocks (see Markdown Format).

Notion API: POST /pages with children blocks

notion_append_content

Append markdown content to the end of an existing page.

Parameter Type Required Description
page_id string yes The page to append to
content string yes Markdown text to append

Content is parsed into blocks and appended. Automatically batches in groups of 100 if the content produces more than 100 blocks (Notion API limit).

Notion API: PATCH /blocks/{id}/children

Editing Page Content

notion_update_page

Update a page's title, icon, or database properties. Does not modify page body content.

Parameter Type Required Default Description
page_id string yes The page to update
title string no "" New title
properties_json string no "" JSON of properties to update
icon string no "" Emoji, or "remove" to clear

For database row pages, properties follow the Notion property value format. Omitted properties are left unchanged.

Notion API: PATCH /pages/{id}

notion_update_page_content

Edit page body content using search-and-replace on the page's markdown. Use notion_read_page_markdown first to see the exact content, then provide the text to find and replace.

Parameter Type Required Description
page_id string yes The page to edit
old_text string yes Exact text to find
new_text string yes Replacement text (empty string to delete)

Notion API: PATCH /pages/{id}/markdown with type: "replace_content_range" (API 2025-09-03+)

notion_replace_page_content

Replace a page's entire body content with new markdown. Destructive — all existing content is overwritten.

Parameter Type Required Description
page_id string yes The page to overwrite
markdown string yes New page content

Notion API: PATCH /pages/{id}/markdown with type: "replace_content" (API 2025-09-03+)

Page Lifecycle

notion_archive_page

Archive (soft-delete) a page. It moves to the Notion trash and can be restored within 30 days.

Parameter Type Required Description
page_id string yes The page to archive

Notion API: PATCH /pages/{id} with {"archived": true}

notion_restore_page

Restore an archived page from the Notion trash.

Parameter Type Required Description
page_id string yes The archived page to restore

Notion API: PATCH /pages/{id} with {"archived": false}

notion_move_page

Move a page to a new parent page or into a database.

Parameter Type Required Default Description
page_id string yes The page to move
new_parent_page_id string no "" Move under this page
new_parent_database_id string no "" Move into this database

Provide exactly one of the parent parameters. When moving into a database, the server automatically resolves the data_source_id.

Notion API: POST /pages/{id}/move (API 2025-09-03+)

Block Operations

Use notion_read_page with include_block_ids=true to get block IDs for these tools.

notion_update_block

Edit an existing block's text content in place.

Parameter Type Required Default Description
block_id string yes The block to edit
content string yes New text (supports inline markdown)
block_type string no auto-detect Block type hint

If block_type is omitted, the server fetches the block to detect its type. Supported types: paragraph, heading_1, heading_2, heading_3, bulleted_list_item, numbered_list_item, to_do, quote, callout, toggle, code.

Notion API: PATCH /blocks/{id}

notion_delete_block

Permanently delete a block and all its children.

Parameter Type Required Description
block_id string yes The block to delete

Notion API: DELETE /blocks/{id}

Database Schema

notion_describe_database

Get a database's full property schema (column names and types).

Parameter Type Required Description
database_id string yes The database to describe

Returns the database_id, data_source_id, and all properties with their types. Use this to discover exact property names (case-sensitive) before querying or creating rows.

Notion API: GET /databases/{id} + GET /data_sources/{id}

notion_create_database

Create a new inline database under a page.

Parameter Type Required Description
parent_page_id string yes Page to create the database under
title string yes Database title
schema_json string yes JSON property schema

The schema_json maps property names to Notion property schema objects. A title property is required:

{
  "Name": {"title": {}},
  "Status": {"select": {"options": [
    {"name": "To Do", "color": "red"},
    {"name": "In Progress", "color": "yellow"},
    {"name": "Done", "color": "green"}
  ]}},
  "Due Date": {"date": {}},
  "Priority": {"number": {"format": "number"}},
  "Tags": {"multi_select": {"options": [
    {"name": "bug", "color": "red"},
    {"name": "feature", "color": "blue"}
  ]}},
  "Assignee": {"people": {}},
  "Done": {"checkbox": {}},
  "Notes": {"rich_text": {}},
  "Link": {"url": {}}
}

Notion API: POST /databases

notion_update_database

Modify a database's title, description, or property schema.

Parameter Type Required Default Description
database_id string yes The database to update
title string no "" New title
description string no "" New description
properties_json string no "" JSON of property changes

Property changes use Notion's property schema format:

// Add a column
{"New Column": {"rich_text": {}}}

// Remove a column
{"Old Column": null}

// Rename a column
{"Old Column": {"name": "New Name"}}

// Add select options
{"Status": {"select": {"options": [{"name": "Blocked", "color": "red"}]}}}

Multiple changes can be combined in one call.

Notion API: PATCH /data_sources/{id} (routed through data source in API 2025-09-03)

Database Queries

notion_query_database

Query database rows with optional filter and sort.

Parameter Type Required Default Description
database_id string yes The database to query
filter_json string no "" Notion filter object as JSON
sorts_json string no "" Notion sort array as JSON
limit int no 50 Page size (1-100 — Notion's hard cap)
start_cursor string no "" Opaque cursor from a previous call. Pass it back to fetch the next page.
properties string no "" Comma-separated property names to include in the output (e.g. "Gmail Message ID, Invoice, Status"). The title column is always shown. Unknown names are ignored silently. Dramatically slims output when you only need a few columns.

Filter examples:

// Simple property filter
{"property": "Status", "select": {"equals": "Done"}}

// Number comparison
{"property": "Priority", "number": {"greater_than": 3}}

// Date filter
{"property": "Due", "date": {"before": "2026-04-01"}}

// Checkbox
{"property": "Done", "checkbox": {"equals": true}}

// Text contains
{"property": "Name", "title": {"contains": "meeting"}}

// Compound filter (AND)
{"and": [
  {"property": "Status", "select": {"equals": "Active"}},
  {"property": "Priority", "number": {"greater_than": 3}}
]}

// Compound filter (OR)
{"or": [
  {"property": "Status", "select": {"equals": "To Do"}},
  {"property": "Status", "select": {"equals": "In Progress"}}
]}

Sort examples:

// Single sort
[{"property": "Due", "direction": "ascending"}]

// Multiple sorts
[
  {"property": "Priority", "direction": "descending"},
  {"property": "Name", "direction": "ascending"}
]

// Sort by timestamp
[{"timestamp": "last_edited_time", "direction": "descending"}]

Results are returned one page at a time. Each response ends with ahas_more: <bool> next_cursor: <token-or-(end)> line; whenhas_more is true, pass the next_cursor value back as start_cursoron the next call to walk the next page. This gives the LLM bounded-context pagination — walk a 10 000-row database 100 rows at a timewithout ever materialising the whole thing in the tool output.

When properties is set, only the listed columns are included in theper-row output (the title column is always shown). This is the fixfor "the query returns the full schema for every row and blows thecontext budget" — set properties="Gmail Message ID,Invoice" andthe response shrinks to just those columns per row.

Notion API: POST /data_sources/{id}/query (API 2025-09-03+; the server resolves database_id to data_source_id automatically)

notion_get_property

Retrieve a single property value with full pagination. Useful for large relation, rollup, or rich_text properties that get truncated in normal page responses.

Parameter Type Required Default Description
page_id string yes The page (row) to read from
property_name string no "" Human-readable property name
property_id string no "" Notion property ID (takes precedence)

Notion API: GET /pages/{id}/properties/{property_id}

Database Rows & Files

notion_upload_file

Upload a file and attach it to a page as a new block.

Parameter Type Required Default Description
source string yes File source (see below)
parent_page_id string yes Page — or any block that supports children (toggle, column, callout, ...) — to attach the file to
filename string no "" Override the source filename
caption string no "" Caption shown below the file in Notion
position string no "end" Where the new block lands: "end", "start", or a block ID to insert directly after that block

The source parameter accepts four schemes:

  • local:<path> — file from the server's local store (/data/files)
  • shared:<filename> — file from the cross-MCP shared mount(/shared). Populated by google-accounts-mcp download_attachment(destination='shared').Preferred path for gmail → notion attachment handoffs; no base64, no size limit.
  • drive:<name-or-id> — file from the shared Google Drive folder
  • base64:<data> — raw base64-encoded content (requires filename).Subject to MCP parameter size limits; prefer shared: or drive:for anything over ~20 KB.

The block type (image, pdf, video, audio, file) is auto-detected from the MIME type. Files over 20 MB are uploaded via Notion's multi-part protocol automatically (10 MiB parts + complete). The response includes the new block's ID and the sha256 of the uploaded bytes.

Notion API: POST /file_uploads (single-part or multi-part) + PATCH /blocks/{id}/children (with position)

notion_replace_file

Replace the file inside an existing file/image/pdf/video/audio block, in place — the block keeps its position on the page. Use this to update a document without re-arranging anything.

Parameter Type Required Default Description
block_id string yes The file block to update (from notion_list_files_on_page)
source string yes Same schemes as notion_upload_file
filename string no "" Override the source filename
caption string no "" Non-empty replaces the caption; empty leaves it untouched

The new file must map to the same block type as the existing block (you cannot replace an image block's content with a PDF — delete and re-upload instead).

Notion API: PATCH /blocks/{id} with a file_upload reference

notion_import_file_from_url

Import a file into Notion directly from a public HTTPS URL — the bytes go Notion-side, never through this server or MCP parameters. The import is asynchronous; the tool polls until done or wait_seconds elapses.

Parameter Type Required Default Description
url string yes Public HTTPS URL
parent_page_id string yes Page (or child-bearing block) to attach to
filename string yes Must carry an extension Notion accepts; determines block type
caption string no "" Caption
position string no "end" As in notion_upload_file
wait_seconds int no 60 Max time to poll for the async import

Size limits are plan-dependent (5 MiB free / 5 GiB paid workspaces).

Notion API: POST /file_uploads with mode=external_url + GET /file_uploads/{id} polling

notion_set_page_visual

Set or remove a page's icon or cover image.

Parameter Type Required Default Description
page_id string yes Target page
target string yes "icon" or "cover"
source string no "" An image via the usual schemes
emoji string no "" Single emoji character (icon only)
filename string no "" Override for source
remove bool no false Clear the icon/cover entirely

Provide exactly one of source, emoji, or remove=True. Covers/icons from source must be images.

Notion API: PATCH /pages/{id} with icon / cover

notion_upload_file_to_database

Create a new database row with a file attached.

Parameter Type Required Default Description
database_id string yes Target database
source string yes File source (same schemes as above)
files_property string yes Exact name of the files column (case-sensitive)
title string yes Title for the new row
title_property string no auto-detect Override the title column name
filename string no "" Override the source filename

Use notion_describe_database first to find the exact files_property name.

Notion API: POST /file_uploads + POST /pages

notion_add_file_to_row

Add a file to an existing database row's files column.

Parameter Type Required Default Description
page_id string yes The row (page) ID
source string yes File source
files_property string yes Exact name of the files column
mode string no "append" "append" or "replace"
filename string no "" Override the source filename

Notion API: POST /file_uploads + PATCH /pages/{id}

notion_batch_add_file_to_row

Attach files to many existing rows in one call.

Parameter Type Required Default Description
items_json string yes JSON array of item objects (see below)
default_files_property string no "" Default files_property for items that omit it
default_mode string no "append" Default mode for items that omit it

Each item in items_json takes the same fields as the single-shottool: page_id, source, optional filename, optionalfiles_property, optional mode. Defaults fill in any field leftoff at the item level.

[
  {"page_id": "p1", "source": "shared:m1_invoice.pdf"},
  {"page_id": "p2", "source": "shared:m2_invoice.pdf", "filename": "Renamed.pdf"},
  {"page_id": "p3", "source": "drive:<file_id>",
   "files_property": "Contracts", "mode": "replace"}
]

Items are processed with bounded concurrency — Notion's file upload APIis not pipelined and rejects rapid parallel stage-2 calls. Per-itemfailures are reported in the output summary ([i] FAIL page=... : ...)but do not abort the batch, so you can retry individual failingitems without re-running the whole set.

Typical pattern: stage N attachments viagoogle-accounts-mcp/download_attachment(destination='shared', prefix=f"{msg}_"),call this tool once, then call notion_purge_file ornotion_purge_shared_files to free the shared slots.

Notion API: same as notion_add_file_to_row, called N times under the hood.

notion_purge_file

Delete a single file from the cross-MCP shared mount by bare filename.

Parameter Type Required Description
filename string yes Bare filename (no path components)

Use after a successful upload to free the shared slot instead ofwaiting for the daily TTL sweep (or calling notion_purge_shared_fileswith max_age_hours=0, which would wipe any unrelated in-flighthandoffs). Rejects any filename containing path separators.

File Downloads

notion_download_file

Download a file from a Notion file/image/pdf/video/audio block.

Parameter Type Required Default Description
block_id string yes File block ID (from notion_list_files_on_page)
destination string no "local" "local", "shared", "drive", or "base64"
filename string no "" Override the inferred filename (bare filename only for shared)

Destinations:

  • local — saves to the server's local file store
  • shared — saves to the cross-MCP mount (/shared), immediatelyattachable by google-accounts-mcp as attachments=['shared:<name>'] ondraft_email/send_email. Collisions auto-rename atomically; 24h TTL.
  • drive — uploads to the shared Google Drive folder
  • base64 — returns the file content as base64 in the response

Every destination reports the sha256 of the downloaded bytes forend-to-end integrity verification.

Notion API: GET /blocks/{id} to get the signed S3 URL, then direct download

PDF Reading & OCR

Read PDFs in place — no download hop needed. Both tools accept any filesource: notion:<block_id> (a file/pdf block on a page), shared:<name>,local:<path>, or drive:<name-or-id>. Semantics are identical togoogle-accounts-mcp's PDF tools (the pdf_read module is duplicated verbatim acrossthe two repos — edit both copies together).

notion_extract_file_text

Extract text from a PDF, with OCR for scanned/image-only pages.

Parameter Type Required Default Description
source string yes notion:<block_id>, shared:<name>, local:<path>, or drive:<name-or-id>
pages string no all pdftotext-style spec: "3", "1-5", "1,3,5", "1-3,7"
mode string no "text" text (flowing), layout (preserve columns), tables (markdown tables). Native extraction only
ocr string no "auto" auto — OCR pages with < 20 chars of native text; off; force — OCR every page; llm — vision model. OCR is delegated to an xberg server (XBERG_BASE_URL, default http://xberg:8000; classical backend tesseract; llm = xberg vlm backend → an OpenAI-compatible vision endpoint, needs XBERG_VLM_API_KEY)

Returns JSON {text, page_count, pages_returned, mode, truncated, filename, source} plus ocr_used/ocr_pages/ocr_engine when OCR ran (andocr_error if auto-mode OCR was needed but the local stack failed —native text still returns). Response ceiling 200 KB, truncated on a pageboundary with a marker naming the pages to fetch next.

notion_render_file_page

Render one PDF page as a viewable MCP image content block — thecalling model sees the page directly (scans, charts, stamps — often noOCR needed).

Parameter Type Required Default Description
source string yes Same schemes as notion_extract_file_text
page int no 1 1-indexed page number
max_width int no 1200 Target pixel width, 200..4000
format string no "jpeg" jpeg or png
quality int no 75 JPEG quality 1..100
return_base64 bool no false true returns JSON {image_base64, mime_type, width, height, page, page_count, filename} instead of an image block

OCR is delegated to an xbergserver — no OCR wheels or OS libs needed here. See google-accounts-mcp's README§ PDF OCR for the full tier/env table (shared pdf_read module).

Comments

notion_get_comments

List all comments on a page.

Parameter Type Required Description
page_id string yes The page to read comments from

Returns comment text, author name, timestamp, and discussion_id for threading.

Notion API: GET /comments with block_id parameter

notion_add_comment

Add a comment to a page, or reply to an existing discussion thread.

Parameter Type Required Default Description
page_id string yes The page to comment on
text string yes Comment text (supports inline markdown)
discussion_id string no "" Reply to this thread (from notion_get_comments)

text is sent as native API markdown (2026-04 addition): inlineformatting, inline equations, and @mentions all work.

Notion API: POST /comments

notion_update_comment

Edit a comment this integration created (the API returns 404 forcomments created by anyone else).

Parameter Type Required Description
comment_id string yes From notion_get_comments / notion_add_comment
text string yes Replacement text (native API markdown)

Notion API: PATCH /comments/{id}

notion_delete_comment

Delete a comment this integration created (404 for anyone else's).

Parameter Type Required Description
comment_id string yes From notion_get_comments / notion_add_comment

Notion API: DELETE /comments/{id}

Views

The Views API (2026-03) exposes the saved views you see as tabs on adatabase — each carries its own filter, sorts, and layout.

notion_list_views

List a database's views: name, type, id, and whether each carries asaved filter/sorts. (The API's list endpoint returns identity-onlyobjects, so each view is hydrated with an extra retrieve.)

Parameter Type Required Description
database_id string yes The database whose views to list
notion_query_view

Run a view's saved filter and sorts — no filter JSON needed. Usethis instead of notion_query_database when a view already encodes thequestion ("Open bugs", "This week").

Parameter Type Required Default Description
view_id string yes From notion_list_views
limit int no 20 Max rows (≤ 100)

Implementation note: the API's view queries are cached objects with a~15-minute TTL; the client creates one, reads a page, and best-effortdeletes it.

notion_create_view / notion_update_view / notion_delete_view
Parameter Type Required Default Description
database_id string create Parent database
view_id string update/delete Target view
name string create "" (update) Display name
view_type string no "table" table / board / list / calendar / timeline / gallery / chart
filter_json string no "" Notion filter object (same shape as notion_query_database)
sorts_json string no "" JSON array of sort objects

Gotcha (verified live): view creation requires both database_idand data_source_id — the client resolves the data source through thesame cache the query path uses. Deleting a view never touches rows.

Users

notion_list_users

List all workspace users (people and bots).

Returns names, IDs, emails, and types. User IDs are needed for people properties and @mentions in comments.

Notion API: GET /users (paginated)

notion_get_user

Get details for a specific user.

Parameter Type Required Description
user_id string yes Notion user UUID

Returns name, email, avatar URL, type (person/bot), and owner info for bots.

Notion API: GET /users/{id}

Markdown Format

Writing (markdown to blocks)

When you pass content to notion_create_page or notion_append_content, the markdown is parsed into Notion blocks. Supported syntax:

Markdown Notion Block
# Heading heading_1
## Heading heading_2
### Heading heading_3
#### Heading heading_4 (2026-03 API addition; deeper levels clamp to it)
Plain text paragraph
- Item or * Item bulleted_list_item
1. Item numbered_list_item
- [x] Done to_do (checked)
- [ ] Open to_do (unchecked)
> Quote quote (consecutive > lines merge)
```python ... ``` code (with language)
--- or *** or ___ divider

Inline formatting within any block:

Markdown Rendering
**bold** Bold
*italic* Italic
***both*** Bold + Italic
`code` Inline code
~~strike~~ Strikethrough
[text](url) Link

Reading (blocks to markdown)

notion_read_page renders all common Notion block types:

  • Paragraphs, headings (1-3), bullet/numbered lists, to-do items
  • Quotes, callouts (with emoji icons), toggles
  • Code blocks (with language), dividers, equations
  • Images (![caption](url)), files, bookmarks, embeds
  • Tables (rendered as markdown tables)
  • Child pages and databases (shown as [Page: Title] / [Database: Title])
  • Nested blocks (indented, recursive up to max_depth)

Working with Databases

API 2025-09-03 Data Source Model

In the 2025-09-03 API, database properties and queries operate through data sources rather than directly on databases. This server handles the mapping transparently:

  1. You pass a database_id (from search results or a Notion URL)
  2. The server calls GET /databases/{id} to find the data_source_id
  3. Schema/query/update operations go through /data_sources/{data_source_id}
  4. The mapping is cached per session for performance

You never need to know or pass data source IDs directly.

Typical Database Workflow

1. notion_search_databases("project")     → find the database ID
2. notion_describe_database(db_id)        → see property names and types
3. notion_query_database(db_id, filter)   → read rows
4. notion_create_page(database_id=db_id)  → add a row
5. notion_update_page(row_id, props)      → update a row's properties

Property Types Reference

The Notion property value format for common types:

// Title (every database has exactly one)
{"Title Column": {"title": [{"text": {"content": "My Title"}}]}}

// Select
{"Status": {"select": {"name": "Done"}}}

// Multi-select
{"Tags": {"multi_select": [{"name": "bug"}, {"name": "urgent"}]}}

// Number
{"Priority": {"number": 5}}

// Date (single)
{"Due": {"date": {"start": "2026-03-15"}}}

// Date (range)
{"Sprint": {"date": {"start": "2026-03-01", "end": "2026-03-15"}}}

// Checkbox
{"Done": {"checkbox": true}}

// URL
{"Link": {"url": "https://example.com"}}

// Email
{"Contact": {"email": "[email protected]"}}

// Rich text
{"Notes": {"rich_text": [{"text": {"content": "Some notes"}}]}}

// People (requires user IDs from notion_list_users)
{"Assignee": {"people": [{"id": "user-uuid-here"}]}}

// Relation (requires page IDs)
{"Related": {"relation": [{"id": "page-uuid-here"}]}}

Working with Files

File Source Schemes

All upload tools accept a source parameter with one of five schemes:

Scheme Format Example Notes
notion notion:<block_id> notion:21f8a834-… A file/image/pdf/video/audio block already in the workspace (block IDs from notion_list_files_on_page). Lets the PDF tools read workspace files in place, and upload tools copy a file between pages.
local local:<path> local:reports/q3.pdf Relative to /data/files in the container (FILES_DIR). List with notion_list_local_files().
shared shared:<filename> shared:invoice.pdf Relative to /shared in the container (SHARED_DIR), the cross-MCP mount shared with the companion google-accounts-mcp server. Populated by google-accounts-mcp/download_attachment(destination='shared'). List with notion_list_local_files(location='shared'). Preferred path for gmail → notion handoffs — no base64, no size limit.
drive drive:<name-or-id> drive:budget.xlsx From the shared Google Drive folder
base64 base64:<data> base64:SGVsbG8= Requires filename parameter. Subject to MCP parameter size limits; prefer shared: or drive: for anything larger than ~20 KB.

File Upload Protocol

Notion uses a 3-stage upload protocol:

  1. CreatePOST /file_uploads returns an upload_id
  2. SendPOST /file_uploads/{id}/send with multipart file data
  3. Reference — attach the upload_id in a block or page property (must be used within 1 hour)

This is handled automatically by all upload tools.

Cross-MCP File Handoff (shared: scheme)

Shared-store invariants: the mount is read/write from both MCPservers. Filename clashes during google-accounts-mcp download_attachment areresolved by auto-renaming (invoice.pdfinvoice-2.pdf), and thereturn value always reports the actual saved name to reference. Filesare swept 24 hours after last modification by thehost-side TTL sweep (if you run one) — stage to Notionwithin that window. For immediate cleanup after a workflow, call thenotion_purge_shared_files tool (see the "Files" tool table).

notion-mcp and google-accounts-mcp share a host volume(~/.local/share/containers/data/mcp-shared/) mounted into bothcontainers at /shared. Files dropped there bygoogle-accounts-mcp download_attachment(destination='shared') are immediatelyreadable via the shared: source scheme:

# From google-accounts-mcp:
download_attachment(message_id="...", filename="invoice.pdf",
                    destination="shared")
# From notion-mcp:
notion_add_file_to_row(page_id="...", source="shared:invoice.pdf",
                       files_property="Attachments")

The pipeline is bidirectional — the reverse direction stages aNotion file for google-accounts-mcp to attach:

# From notion-mcp:
notion_download_file(block_id="...", destination="shared")
# From google-accounts-mcp:
draft_email(to=["..."], subject="...", body="...",
            attachments=["shared:contract.pdf"])

The file bytes never traverse MCP tool parameters, so there is no sizelimit and the 2026-04 "Energy Locals 1.57 MB invoice" pipeline failureno longer applies. Use this path by default for file transfers betweenthe two servers in either direction. All transfer tools report sha256so the agent can verify integrity at each hop ( notion_list_local_filesand gmail's list_shared_files include it per file too).

Google Drive Integration

The Drive integration is an optional companion feature: it reuses OAuth refresh tokens from google-accounts-mcp (mounted read-only at /google-data/tokens.db) using the same Google client ID. Without that companion server, skip the Drive env vars — every other file source keeps working, and drive: sources return a clear setup error. Use drive: when you want the file to persist in Drive; use shared: when you just need a one-shot handoff between the two MCP servers.

Common Workflows

Read a page and make a targeted edit

1. notion_read_page_markdown(page_id)           → see current content
2. notion_update_page_content(page_id,
     old_text="Draft version",
     new_text="**Final** version")               → search-replace edit

Create a meeting notes page

1. notion_create_page(
     title="Sprint Planning 2026-03-15",
     parent_page_id="meetings-page-id",
     icon="📋",
     content="# Agenda\n\n- [ ] Review backlog\n- [ ] Assign tasks\n\n# Notes\n\n")

Query a database and update a row

1. notion_query_database(db_id,
     filter_json='{"property": "Status", "select": {"equals": "In Progress"}}')
2. notion_update_page(row_id,
     properties_json='{"Status": {"select": {"name": "Done"}}}')

Move a page into a database

1. notion_move_page(page_id, new_parent_database_id=db_id)

Download a file from Notion to Drive

1. notion_list_files_on_page(page_id)            → get block_id
2. notion_download_file(block_id, destination="drive")

Email a file stored in Notion

1. notion_list_files_on_page(page_id)            → get block_id
2. notion_download_file(block_id, destination="shared")
3. google-accounts-mcp/draft_email(..., attachments=["shared:<filename>"])

Edit a specific block on a page

1. notion_read_page(page_id, include_block_ids=true)  → find block UUID
2. notion_update_block(block_id, "New **content**")    → edit in place

Configuration

All config is via environment variables. For container deployment, put secrets in .secrets.env:

Variable Required Default Description
NOTION_TOKEN Yes Internal integration token (starts with ntn_)
MCP_BEARER_TOKEN Yes Bearer token for authenticating MCP clients
NOTION_API_VERSION No 2026-03-11 Notion API version header
PORT No 8322 Server listen port
FILES_DIR No /data/files Local file store path
GOOGLE_CLIENT_ID For Drive OAuth client ID (same as google-accounts-mcp)
GOOGLE_CLIENT_SECRET For Drive OAuth client secret
GOOGLE_TOKEN_DB_PATH For Drive /google-data/tokens.db Path to google-accounts-mcp's SQLite token DB (read-only; the legacy GMAIL_TOKEN_DB_PATH name is still honored)
DRIVE_ACCOUNT For Drive Google account email for Drive access
DRIVE_FOLDER_NAME For Drive mcp-google-accounts Shared Drive folder name

Architecture

src/notion_mcp/
  server.py          # FastMCP tool definitions, HTTP server, bearer auth middleware
  notion_client.py   # Notion REST client — all API calls, pagination, caching
  blocks.py          # Bidirectional Notion blocks <-> markdown conversion
  drive_client.py    # Google Drive client (reuses google-accounts-mcp OAuth tokens)
  config.py          # Environment variable configuration

Key design decisions:

  • notion_client.py handles all HTTP, pagination, and the database → data_source mapping. Tools in server.py never call the Notion API directly.
  • blocks.py is a pure-function module with zero side effects — easy to test and reuse.
  • Data source IDs are cached in memory per NotionClient instance to avoid redundant lookups.
  • Block appends are auto-batched at 100 (Notion API limit).
  • The bearer auth middleware is pure ASGI, compatible with streamed responses.
  • Threadpool tool offload (server.py): the MCP SDK runs synchronous @mcp.tool() handlers inline on the event loop, so one blocking httpx call would freeze every other request (the cause of the "server unresponsive after a burst" stalls). mcp.tool is wrapped so each sync tool runs its body in a worker thread (anyio.to_thread.run_sync); the loop stays free. The wrapper preserves the tool signature (client schemas unchanged — no restart) and returns the original sync function as the module name (tool-to-tool calls and tests still work). Each call logs tool=… outcome=… duration_ms=… for Loki.
  • Rate-limit backoff (notion_client._request): retries 429/502/503/504 up to 4 attempts honouring Retry-After (capped 30s/attempt). The backoff sleep runs in the offload thread, never blocking the loop. send_file_upload timeout is capped at 180s — below the MCP client's 240s ceiling — so the server fails before the client does.
  • Container healthcheck: python -m notion_mcp.healthcheck does a full HTTP round-trip to /mcp; a wedged loop fails the probe and HealthOnFailure=kill + Restart=always restart the container.

Development

Requires Python 3.12+ and uv.

# Install all dependencies including test extras
uv sync --extra test

# Run the server locally
NOTION_TOKEN=ntn_... uv run notion-mcp

# Run unit tests (fast, no API calls needed)
uv run --extra test pytest tests/ --ignore=tests/test_integration.py -v

# Run all tests including live API
NOTION_TEST_TOKEN=ntn_... uv run --extra test pytest -v

Testing

Unit Tests (203 tests, ~2s)

No API access required. Seven test files (the PDF-tool tests mock thexberg HTTP calls — no network):

test_blocks.py (77 tests) — markdown conversion:

  • rich_text_to_md / md_to_rich_text: all inline formatting variants
  • blocks_to_markdown: every block type, nesting, include_block_ids, tables
  • markdown_to_blocks: headings, lists, quotes, code, dividers, mixed content
  • format_property_value: all 20+ Notion property types
  • format_page_properties: multi-property formatting

test_notion_client.py (50 tests) — client logic with mocked HTTP:

  • Pagination for list_children, query_data_source, list_comments, list_users
  • resolve_data_source_id caching and error handling
  • create_page child batching (>100 blocks)
  • Request body construction for markdown, move, comment endpoints
  • pick_block_type MIME mapping
  • find_title_property / get_page_title edge cases

test_server_tools.py (38 tests) — tool-layer logic with a mocked client(file source resolution, positional inserts, replace flows, purge tools).

test_sources.py (13 tests) — _read_source scheme parsing forlocal:/shared:/base64: (the drive: scheme lives in test_drive_client.py).

test_purge.py (6 tests) — notion_purge_shared_files filters.

test_pdf_tools.py (16 tests) — notion_extract_file_text /notion_render_file_page wiring, the notion: source scheme, image vsbase64 return modes, OCR plumbing (xberg calls mocked; full OCR-tiersemantics are covered in google-accounts-mcp's test_pdf_read.py, sincepdf_read.py is duplicated verbatim across the two repos).

test_drive_client.py (3 tests) — Drive client; guards the read-onlytokens-DB regression (sqlite must open with immutable=1).

uv run --extra test pytest tests/ --ignore=tests/test_integration.py -v

Integration Tests (17 tests, ~60s)

Hit the live Notion API against a sandbox page you provide. Require:

  • NOTION_TEST_TOKEN — an integration token with access to the sandbox
  • NOTION_TEST_PAGE_ID — a scratch page the tests may write to
  • NOTION_TEST_DB_ID — an inline database on that page

Tests create pages, exercise all endpoints, and clean up after themselves:

  • Connectivity (search, list users, retrieve page)
  • Page CRUD (create, read, archive, restore)
  • Blocks (append, read, update, delete)
  • Markdown API (read, replace, search-replace, insert)
  • Data sources (resolve, get schema, query, search)
  • Move page (create parent A/B, move child, verify)
  • Comments (create, list)
NOTION_TEST_TOKEN=ntn_... uv run --extra test pytest tests/test_integration.py -v

Run integration tests after:

  • Upgrading NOTION_API_VERSION in config.py
  • Changing endpoint paths or request body formats
  • Modifying pagination or error handling

Local stdio Mode

--stdio starts FastMCP's stdio transport: no uvicorn, no bearer token(the client owns the spawned process; there is no network surface). Thisis what most interactive MCP clients should use:

// e.g. Claude Desktop claude_desktop_config.json / Claude Code .mcp.json
{
  "mcpServers": {
    "notion": {
      "command": "uv",
      "args": ["run", "--project", "/path/to/notion-mcp",
               "notion-mcp", "--stdio"],
      "env": {
        "NOTION_TOKEN": "ntn_...",
        "FILES_DIR": "/home/you/.local/share/notion-mcp/files"
      }
    }
  }
}

Prefer an env file over inline values where your client supports it(uv run --env-file ...).

Container Deployment (HTTP)

podman build -t notion-mcp .   # or: docker build -t notion-mcp .
podman run -d --name notion-mcp -p 8322:8322 -v notion-data:/data \
  -e NOTION_TOKEN=ntn_... \
  -e MCP_BEARER_TOKEN=some-long-random-token \
  notion-mcp

HTTP mode refuses to start without MCP_BEARER_TOKEN; clientsregister with:

{
  "mcpServers": {
    "notion": {
      "url": "https://your-host:8322/mcp",
      "headers": {"Authorization": "Bearer <MCP_BEARER_TOKEN>"}
    }
  }
}

notion_mcp.healthcheck does a full HTTP round-trip to /mcp (the 401counts as alive); wire it to your container healthcheck. Terminate TLS ata reverse proxy — the server itself speaks plain HTTP. For the optionalDrive/shared-mount features, add the /google-data (read-only) and/shared mounts shared with a google-accounts-mcp container.

Notion API Version Notes

This server uses Notion API 2026-03-11. Version history:

2025-09-03 (from 2022-06-28)

Feature Old (2022-06-28) New (2025-09-03)
Database properties On /databases/{id} response Moved to /data_sources/{id}
Database query POST /databases/{id}/query POST /data_sources/{id}/query
Database search filter.value = "database" filter.value = "data_source"
Markdown read Not available GET /pages/{id}/markdown
Markdown write Not available PATCH /pages/{id}/markdown
Move pages Not available POST /pages/{id}/move

2026-03-11 (from 2025-09-03)

Feature Old (2025-09-03) New (2026-03-11)
Trash status field "archived": true/false "in_trash": true/false
Block append positioning "after": "block-id" "position": {"type": "after_block", "after_block": {"id": "..."}}
Transcription block type "transcription" "meeting_notes"

The position object also supports {"type": "start"} and {"type": "end"} for inserting at the beginning or end of a parent block.

The server handles the data source migration transparently — you always pass database_id and the server resolves the data_source_id internally (with caching).

If upgrading from an older version, run the integration test suite to verify nothing breaks:

NOTION_TEST_TOKEN=ntn_... uv run --extra test pytest tests/test_integration.py -v

License

MIT

MCP Server · Populars

MCP Server · New

    lyc403223157-source

    Knowledge Inbox

    Local-first knowledge ingestion for AI agents and Obsidian

    ipiton

    agent-memory-mcp

    MCP server that gives AI agents persistent memory with semantic search

    Community ipiton
    dialog-tools

    Dialog MCP Server

    Turn Reddit's chaos into structured insights with full citations. MCP server for competitive analysis, customer discovery, and market research. Zero-setup hosted solution with semantic search across 20,000+ subreddits.

    Community dialog-tools
    Bevel-Software

    hexis

    Git-backed skills, tools & context for AI agents

    Community Bevel-Software
    jonashertner

    OpenCaseLaw

    Open Swiss legal corpus + MCP server: 1M+ court decisions (1875–today), 21k laws, 10M-edge citation graph, 42 MCP tools. CC0 data, MIT code. Live at mcp.opencaselaw.ch

    Community jonashertner