Metadata-Version: 2.4
Name: notion-mcp
Version: 0.1.0
Summary: MCP server for Notion: pages, databases, markdown, comments, and a full file pipeline
Project-URL: Homepage, https://github.com/snickery/notion-mcp
Project-URL: Issues, https://github.com/snickery/notion-mcp/issues
Author-email: Nick Engel <nick@engel.au>
License-Expression: MIT
License-File: LICENSE
Keywords: files,markdown,mcp,model-context-protocol,notion
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business
Requires-Python: >=3.12
Requires-Dist: google-api-python-client>=2.150.0
Requires-Dist: google-auth-httplib2>=0.2.0
Requires-Dist: google-auth>=2.35.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: mcp[cli]<2,>=1.0.0
Requires-Dist: pdfplumber>=0.11
Requires-Dist: pillow>=10.0
Requires-Dist: pypdfium2>=4.30
Requires-Dist: uvicorn>=0.32.0
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == 'test'
Description-Content-Type: text/markdown

# 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 (`![caption](url)`), files, bookmarks, embeds
- Tables (rendered as markdown tables)
- Child pages and databases (shown as `[Page: Title]` / `[Database: Title]`)
- Nested blocks (indented, recursive up to `max_depth`)

---

## Working with Databases

### API 2025-09-03 Data Source Model

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

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

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

### Typical Database Workflow

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

### Property Types Reference

The [Notion property value format](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)
