notion-mcp
Provides tools for interacting with Notion's API, enabling management of pages, databases, comments, users, and files, including markdown read/write, database queries, and file upload/download with PDF OCR.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@notion-mcpSearch my Notion for pages about 'Q3 planning' and summarize the first one."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 file pipeline — upload/replace/import/download with positional inserts, page icons/covers, multi-part upload for large files, and in-place PDF reading with optional OCR. Built on the Python MCP SDK (FastMCP); runs as a local stdio server or a containerized Streamable HTTP service with bearer auth. 44 tools.
Unofficial project, not affiliated with or endorsed by Notion.
Table of Contents
Related MCP server: Markdown-To-Notion
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/mcpCreate 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 |
| string | yes | Text to search for | |
| int | no | 10 | Max results (1-100) |
| bool | no |
| Search trashed pages instead of active ones (2026-07 API addition; restore hits with |
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 |
| string | yes | Text to search for | |
| 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 |
| 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 |
| string | no |
| Substring filter on filename |
| string | no |
|
|
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 |
| float | no |
| Minimum file age before deletion. Must be ≥ 0. |
| bool | no |
| 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 |
| string | yes | The page to read | |
| int | no | 3 | Recursion depth for nested blocks (1-5) |
| bool | no | false | Suffix each line with |
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 |
| 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 markdownnotion_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 |
| string | yes | Page title | |
| string | no |
| Markdown body content |
| string | no |
| Create as child of this page |
| string | no |
| Create as row in this database |
| string | no |
| JSON object of Notion property values |
| 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
childrenblocks
notion_append_content
Append markdown content to the end of an existing page.
Parameter | Type | Required | Description |
| string | yes | The page to append to |
| 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 |
| string | yes | The page to update | |
| string | no |
| New title |
| string | no |
| JSON of properties to update |
| string | no |
| Emoji, or |
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 |
| string | yes | The page to edit |
| string | yes | Exact text to find |
| 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 |
| string | yes | The page to overwrite |
| 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 |
| 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 |
| 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 |
| string | yes | The page to move | |
| string | no |
| Move under this page |
| 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 |
| string | yes | The block to edit | |
| string | yes | New text (supports inline markdown) | |
| 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 |
| 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 |
| 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 |
| string | yes | Page to create the database under |
| string | yes | Database title |
| 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 |
| string | yes | The database to update | |
| string | no |
| New title |
| string | no |
| New description |
| 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 |
| string | yes | The database to query | |
| string | no |
| Notion filter object as JSON |
| string | no |
| Notion sort array as JSON |
| int | no | 50 | Page size (1-100 — Notion's hard cap) |
| string | no |
| Opaque cursor from a previous call. Pass it back to fetch the next page. |
| string | no |
| Comma-separated property names to include in the output (e.g. |
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 a
has_more: <bool> next_cursor: <token-or-(end)> line; when
has_more is true, pass the next_cursor value back as start_cursor
on 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 time
without ever materialising the whole thing in the tool output.
When properties is set, only the listed columns are included in the
per-row output (the title column is always shown). This is the fix
for "the query returns the full schema for every row and blows the
context budget" — set properties="Gmail Message ID,Invoice" and
the response shrinks to just those columns per row.
Notion API: POST /data_sources/{id}/query (API 2025-09-03+; the server resolves
database_idtodata_source_idautomatically)
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 |
| string | yes | The page (row) to read from | |
| string | no |
| Human-readable property name |
| 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 |
| string | yes | File source (see below) | |
| string | yes | Page — or any block that supports children (toggle, column, callout, ...) — to attach the file to | |
| string | no |
| Override the source filename |
| string | no |
| Caption shown below the file in Notion |
| string | no |
| Where the new block lands: |
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 bygoogle-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 folderbase64:<data>— raw base64-encoded content (requiresfilename). Subject to MCP parameter size limits; prefershared:ordrive: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 |
| string | yes | The file block to update (from | |
| string | yes | Same schemes as | |
| string | no |
| Override the source filename |
| 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_uploadreference
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 |
| string | yes | Public HTTPS URL | |
| string | yes | Page (or child-bearing block) to attach to | |
| string | yes | Must carry an extension Notion accepts; determines block type | |
| string | no |
| Caption |
| string | no |
| As in |
| int | no |
| 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 |
| string | yes | Target page | |
| string | yes |
| |
| string | no |
| An image via the usual schemes |
| string | no |
| Single emoji character (icon only) |
| string | no |
| Override for |
| bool | no |
| 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 |
| string | yes | Target database | |
| string | yes | File source (same schemes as above) | |
| string | yes | Exact name of the files column (case-sensitive) | |
| string | yes | Title for the new row | |
| string | no | auto-detect | Override the title column name |
| 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 |
| string | yes | The row (page) ID | |
| string | yes | File source | |
| string | yes | Exact name of the files column | |
| string | no |
|
|
| 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 |
| string | yes | JSON array of item objects (see below) | |
| string | no |
| Default |
| string | no |
| Default |
Each item in items_json takes the same fields as the single-shot
tool: page_id, source, optional filename, optional
files_property, optional mode. Defaults fill in any field left
off 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 API
is not pipelined and rejects rapid parallel stage-2 calls. Per-item
failures are reported in the output summary ([i] FAIL page=... : ...)
but do not abort the batch, so you can retry individual failing
items without re-running the whole set.
Typical pattern: stage N attachments via
google-accounts-mcp/download_attachment(destination='shared', prefix=f"{msg}_"),
call this tool once, then call notion_purge_file or
notion_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 |
| string | yes | Bare filename (no path components) |
Use after a successful upload to free the shared slot instead of
waiting for the daily TTL sweep (or calling notion_purge_shared_files
with max_age_hours=0, which would wipe any unrelated in-flight
handoffs). 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 |
| string | yes | File block ID (from | |
| string | no |
|
|
| string | no |
| Override the inferred filename (bare filename only for |
Destinations:
local— saves to the server's local file storeshared— saves to the cross-MCP mount (/shared), immediately attachable by google-accounts-mcp asattachments=['shared:<name>']ondraft_email/send_email. Collisions auto-rename atomically; 24h TTL.drive— uploads to the shared Google Drive folderbase64— returns the file content as base64 in the response
Every destination reports the sha256 of the downloaded bytes for
end-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 file
source: notion:<block_id> (a file/pdf block on a page), shared:<name>,
local:<path>, or drive:<name-or-id>. Semantics are identical to
google-accounts-mcp's PDF tools (the pdf_read module is duplicated verbatim across
the 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 |
| string | yes |
| |
| string | no | all |
|
| string | no |
|
|
| string | no |
|
|
Returns JSON {text, page_count, pages_returned, mode, truncated, filename, source} plus ocr_used/ocr_pages/ocr_engine when OCR ran (and
ocr_error if auto-mode OCR was needed but the local stack failed —
native text still returns). Response ceiling 200 KB, truncated on a page
boundary with a marker naming the pages to fetch next.
notion_render_file_page
Render one PDF page as a viewable MCP image content block — the calling model sees the page directly (scans, charts, stamps — often no OCR needed).
Parameter | Type | Required | Default | Description |
| string | yes | Same schemes as | |
| int | no |
| 1-indexed page number |
| int | no |
| Target pixel width, 200..4000 |
| string | no |
|
|
| int | no |
| JPEG quality 1..100 |
| bool | no |
|
|
OCR is delegated to an xberg
server — 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 |
| 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_idparameter
notion_add_comment
Add a comment to a page, or reply to an existing discussion thread.
Parameter | Type | Required | Default | Description |
| string | yes | The page to comment on | |
| string | yes | Comment text (supports inline markdown) | |
| string | no |
| Reply to this thread (from |
text is sent as native API markdown (2026-04 addition): inline
formatting, inline equations, and @mentions all work.
Notion API: POST /comments
notion_update_comment
Edit a comment this integration created (the API returns 404 for comments created by anyone else).
Parameter | Type | Required | Description |
| string | yes | From |
| 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 |
| string | yes | From |
Notion API: DELETE /comments/{id}
Views
The Views API (2026-03) exposes the saved views you see as tabs on a database — each carries its own filter, sorts, and layout.
notion_list_views
List a database's views: name, type, id, and whether each carries a saved filter/sorts. (The API's list endpoint returns identity-only objects, so each view is hydrated with an extra retrieve.)
Parameter | Type | Required | Description |
| string | yes | The database whose views to list |
notion_query_view
Run a view's saved filter and sorts — no filter JSON needed. Use
this instead of notion_query_database when a view already encodes the
question ("Open bugs", "This week").
Parameter | Type | Required | Default | Description |
| string | yes | From | |
| int | no |
| 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-effort deletes it.
notion_create_view / notion_update_view / notion_delete_view
Parameter | Type | Required | Default | Description |
| string | create | Parent database | |
| string | update/delete | Target view | |
| string | create |
| Display name |
| string | no |
| table / board / list / calendar / timeline / gallery / chart |
| string | no |
| Notion filter object (same shape as |
| string | no |
| JSON array of sort objects |
Gotcha (verified live): view creation requires both database_id
and data_source_id — the client resolves the data source through the
same 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 |
| 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Inline formatting within any block:
Markdown | Rendering |
| Bold |
| Italic |
| Bold + Italic |
| Inline code |
| Strikethrough |
| 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 (
), files, bookmarks, embedsTables (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:
You pass a
database_id(from search results or a Notion URL)The server calls
GET /databases/{id}to find thedata_source_idSchema/query/update operations go through
/data_sources/{data_source_id}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 propertiesProperty 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": "user@example.com"}}
// 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 |
|
|
| A file/image/pdf/video/audio block already in the workspace (block IDs from |
|
|
| Relative to |
|
|
| Relative to |
|
|
| From the shared Google Drive folder |
|
|
| Requires |
File Upload Protocol
Notion uses a 3-stage upload protocol:
Create —
POST /file_uploadsreturns anupload_idSend —
POST /file_uploads/{id}/sendwith multipart file dataReference — attach the
upload_idin 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 MCP
servers. Filename clashes during google-accounts-mcp download_attachment are
resolved by auto-renaming (invoice.pdf → invoice-2.pdf), and the
return value always reports the actual saved name to reference. Files
are swept 24 hours after last modification by the
host-side TTL sweep (if you run one) — stage to Notion
within that window. For immediate cleanup after a workflow, call the
notion_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 both
containers at /shared. Files dropped there by
google-accounts-mcp download_attachment(destination='shared') are immediately
readable 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 a Notion 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 size
limit and the 2026-04 "Energy Locals 1.57 MB invoice" pipeline failure
no longer applies. Use this path by default for file transfers between
the two servers in either direction. All transfer tools report sha256
so the agent can verify integrity at each hop ( notion_list_local_files
and 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 editCreate 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 placeConfiguration
All config is via environment variables. For container deployment, put secrets in .secrets.env:
Variable | Required | Default | Description |
| Yes | Internal integration token (starts with | |
| Yes | Bearer token for authenticating MCP clients | |
| No |
| Notion API version header |
| No |
| Server listen port |
| No |
| Local file store path |
| For Drive | OAuth client ID (same as google-accounts-mcp) | |
| For Drive | OAuth client secret | |
| For Drive |
| Path to google-accounts-mcp's SQLite token DB (read-only; the legacy |
| For Drive | Google account email for Drive access | |
| For Drive |
| 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 configurationKey design decisions:
notion_client.pyhandles all HTTP, pagination, and the database → data_source mapping. Tools inserver.pynever call the Notion API directly.blocks.pyis a pure-function module with zero side effects — easy to test and reuse.Data source IDs are cached in memory per
NotionClientinstance 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 blockinghttpxcall would freeze every other request (the cause of the "server unresponsive after a burst" stalls).mcp.toolis 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 logstool=… outcome=… duration_ms=…for Loki.Rate-limit backoff (
notion_client._request): retries429/502/503/504up to 4 attempts honouringRetry-After(capped 30s/attempt). The backoff sleep runs in the offload thread, never blocking the loop.send_file_uploadtimeout 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.healthcheckdoes a full HTTP round-trip to/mcp; a wedged loop fails the probe andHealthOnFailure=kill+Restart=alwaysrestart 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 -vTesting
Unit Tests (203 tests, ~2s)
No API access required. Seven test files (the PDF-tool tests mock the xberg HTTP calls — no network):
test_blocks.py (77 tests) — markdown conversion:
rich_text_to_md/md_to_rich_text: all inline formatting variantsblocks_to_markdown: every block type, nesting,include_block_ids, tablesmarkdown_to_blocks: headings, lists, quotes, code, dividers, mixed contentformat_property_value: all 20+ Notion property typesformat_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_usersresolve_data_source_idcaching and error handlingcreate_pagechild batching (>100 blocks)Request body construction for markdown, move, comment endpoints
pick_block_typeMIME mappingfind_title_property/get_page_titleedge 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 for
local:/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 vs
base64 return modes, OCR plumbing (xberg calls mocked; full OCR-tier
semantics are covered in google-accounts-mcp's test_pdf_read.py, since
pdf_read.py is duplicated verbatim across the two repos).
test_drive_client.py (3 tests) — Drive client; guards the read-only
tokens-DB regression (sqlite must open with immutable=1).
uv run --extra test pytest tests/ --ignore=tests/test_integration.py -vIntegration 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 sandboxNOTION_TEST_PAGE_ID— a scratch page the tests may write toNOTION_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 -vRun integration tests after:
Upgrading
NOTION_API_VERSIONinconfig.pyChanging 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). This
is 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-mcpHTTP mode refuses to start without MCP_BEARER_TOKEN; clients
register 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 401
counts as alive); wire it to your container healthcheck. Terminate TLS at
a reverse proxy — the server itself speaks plain HTTP. For the optional
Drive/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 | Moved to |
Database query |
|
|
Database search |
|
|
Markdown read | Not available |
|
Markdown write | Not available |
|
Move pages | Not available |
|
2026-03-11 (from 2025-09-03)
Feature | Old (2025-09-03) | New (2026-03-11) |
Trash status field |
|
|
Block append positioning |
|
|
Transcription block type |
|
|
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 -vLicense
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- -license-quality-maintenanceAn MCP server that enables natural language interaction with the Notion API, allowing users to search, comment, create pages, and access content within their Notion workspace.Last updated194,217
- AlicenseCqualityDmaintenanceAn MCP server that converts Markdown content to Notion API-compatible formats, suitable for content management and development integration.Last updated11Apache 2.0
- AlicenseAqualityCmaintenanceAn MCP server for Notion API with optimized token efficiency and full database property filtering, enabling AI assistants to manage pages, databases, and blocks.Last updated32801MIT
- Alicense-qualityDmaintenanceMCP server for the Notion API, enabling management of pages, blocks, databases, data sources, comments, and users through natural language.Last updated312MIT
Related MCP Connectors
Markdown-first MCP server for Notion API with 8 composite tools and 39 actions.
MCP-native open-source Notion alternative: read & write pages, databases and kanban boards.
A MCP server built for developers enabling Git based project management with project and personal…
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/snickery/notion-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server