notion-mcp
# notion-mcp
An [MCP](https://modelcontextprotocol.io) 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](https://github.com/modelcontextprotocol/python-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
- [Quick Start](#quick-start)
- [Tool Reference](#tool-reference)
- [Search & Discovery](#search--discovery)
- [Reading Pages](#reading-pages)
- [Creating & Writing Pages](#creating--writing-pages)
- [Editing Page Content](#editing-page-content)
- [Page Lifecycle](#page-lifecycle)
- [Block Operations](#block-operations)
- [Database Schema](#database-schema)
- [Database Queries](#database-queries)
- [Database Rows & Files](#database-rows--files)
- [File Downloads](#file-downloads)
- [PDF Reading & OCR](#pdf-reading--ocr)
- [Comments](#comments)
- [Users](#users)
- [Markdown Format](#markdown-format)
- [Working with Databases](#working-with-databases)
- [Working with Files](#working-with-files)
- [Common Workflows](#common-workflows)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [Development](#development)
- [Testing](#testing)
- [Local stdio Mode](#local-stdio-mode)
- [Container Deployment](#container-deployment)
- [MCP Client Registration](#mcp-client-registration)
- [Notion API Version Notes](#notion-api-version-notes)
## Quick Start
```bash
# 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](https://www.notion.so/my-integrations) 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](https://developers.notion.com/reference/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](https://developers.notion.com/reference/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](https://developers.notion.com/reference/get-block-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](https://developers.notion.com/reference/get-block-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](https://developers.notion.com/reference/retrieve-page-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](https://developers.notion.com/reference/property-value-object):
```json
{
"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](#markdown-format)).
> Notion API: [POST /pages](https://developers.notion.com/reference/post-page) 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](https://developers.notion.com/reference/patch-block-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](https://developers.notion.com/reference/property-value-object). Omitted properties are left unchanged.
> Notion API: [PATCH /pages/{id}](https://developers.notion.com/reference/patch-page)
#### `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](https://developers.notion.com/reference/update-page-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](https://developers.notion.com/reference/update-page-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}](https://developers.notion.com/reference/patch-page) 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}](https://developers.notion.com/reference/patch-page) 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](https://developers.notion.com/reference/move-page) (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}](https://developers.notion.com/reference/update-a-block)
#### `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}](https://developers.notion.com/reference/delete-a-block)
---
### 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}](https://developers.notion.com/reference/retrieve-a-database) + [GET /data_sources/{id}](https://developers.notion.com/reference/retrieve-a-data-source)
#### `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](https://developers.notion.com/reference/property-schema-object). A title property is required:
```json
{
"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](https://developers.notion.com/reference/create-a-database)
#### `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](https://developers.notion.com/reference/update-property-schema-object):
```json
// 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}](https://developers.notion.com/reference/update-a-data-source) (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](https://developers.notion.com/reference/post-database-query-filter) as JSON |
| `sorts_json` | string | no | `""` | [Notion sort array](https://developers.notion.com/reference/post-database-query-sort) 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:**
```json
// 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:**
```json
// 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](https://developers.notion.com/reference/query-a-data-source) (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}](https://developers.notion.com/reference/retrieve-a-page-property-item)
---
### 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](https://developers.notion.com/reference/create-a-file-upload) (single-part or multi-part) + [PATCH /blocks/{id}/children](https://developers.notion.com/reference/patch-block-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}](https://developers.notion.com/reference/update-a-block) 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](https://developers.notion.com/reference/create-a-file-upload) with `mode=external_url` + [GET /file_uploads/{id}](https://developers.notion.com/reference/retrieve-a-file-upload) 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}](https://developers.notion.com/reference/patch-page) 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](https://developers.notion.com/reference/create-a-file-upload) + [POST /pages](https://developers.notion.com/reference/post-page)
#### `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](https://developers.notion.com/reference/create-a-file-upload) + [PATCH /pages/{id}](https://developers.notion.com/reference/patch-page)
#### `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-shot
tool: `page_id`, `source`, optional `filename`, optional
`files_property`, optional `mode`. Defaults fill in any field left
off at the item level.
```json
[
{"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 |
|-----------|------|----------|-------------|
| `filename` | 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 |
|-----------|------|----------|---------|-------------|
| `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`), immediately
attachable by google-accounts-mcp as `attachments=['shared:<name>']` on
`draft_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 for
end-to-end integrity verification.
> Notion API: [GET /blocks/{id}](https://developers.notion.com/reference/retrieve-a-block) 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 |
|-----------|------|----------|---------|-------------|
| `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](https://github.com/xberg-io/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 (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 |
|-----------|------|----------|---------|-------------|
| `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 [xberg](https://github.com/xberg-io/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 |
|-----------|------|----------|-------------|
| `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](https://developers.notion.com/reference/retrieve-a-comment) 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): inline
formatting, inline equations, and @mentions all work.
> Notion API: [POST /comments](https://developers.notion.com/reference/create-a-comment)
#### `notion_update_comment`
Edit a comment this integration created (the API returns 404 for
comments 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}](https://developers.notion.com/reference/update-a-comment)
#### `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}](https://developers.notion.com/reference/delete-a-comment)
---
### 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 |
|-----------|------|----------|-------------|
| `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. Use
this instead of `notion_query_database` when a view already encodes the
question ("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-effort
deletes 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_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](https://developers.notion.com/reference/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}](https://developers.notion.com/reference/get-user)
---
## 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 (``), 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](https://developers.notion.com/reference/property-value-object) for common types:
```json
// 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 |
|--------|--------|---------|-------|
| `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. **Create** — `POST /file_uploads` returns an `upload_id`
2. **Send** — `POST /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 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:
```python
# 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:
```python
# 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](https://github.com/snickery/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](https://www.notion.so/my-integrations) (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](https://docs.astral.sh/uv/).
```bash
# 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 the
xberg 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 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`).
```bash
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)
```bash
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). This
is what most interactive MCP clients should use:
```jsonc
// 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)
```bash
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`; clients
register with:
```json
{
"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 `/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:
```bash
NOTION_TEST_TOKEN=ntn_... uv run --extra test pytest tests/test_integration.py -v
```
## License
[MIT](LICENSE)
TDQS
Scored across 44 tools
Most tools target a distinct Notion resource/action, but a few close pairs (e.g., notion_read_page vs notion_read_page_markdown, notion_update_page vs notion_update_page_content vs notion_replace_page_content) could cause misselection without careful reading of descriptions. Overall, descriptions are detailed enough to disambiguate, so the overlap is manageable.
All tools share the 'notion_' prefix and generally follow verb_noun naming, but the verbs are inconsistent across similar actions (add vs upload, get vs read/list/query, update vs replace/append). This inconsistency creates some friction, though the pattern is still predictable overall.
44 tools is far beyond the typical well-scoped range and feels heavy for a single server, even for a broad API like Notion. Several tools could be consolidated (e.g., three ways to modify page content, multiple file-purge variants), and the sheer number increases cognitive load and misselection risk.
The tool set provides thorough coverage of Notion's core surface: CRUD for pages, databases, blocks, comments, views, and users, plus rich file operations (upload, download, replace, extract, render, batch attach). There are few obvious gaps—the domain appears fully supported for common workflows.