Search, read and write your Notion workspaces from
your AI assistant, several through one server: pages as Markdown, databases
queried and counted by column, saved views, people, comments and files. It
creates pages, rows, columns and databases, edits properties and bodies,
comments, uploads files, and moves pages to Notion's trash (kept 30 days;
restore_page brings them back). No tool is money.
You need a Notion integration per workspace, its token in a file of its own, and your top-level pages shared with it: an integration sees only what you share. Uploads take files up to 20 MiB (5 MiB on Notion's Free plan).
What you can ask
- "Find the Q3 roadmap page in my work workspace and summarise it."
- "How many rows of the Tasks database are in each Status?"
- "Show me my Due this week view."
- "Add 'Thinking in Systems' to the Reading List with status To read and the tag Books."
- "In the Onboarding page, replace 'Slack' with 'Mattermost' everywhere."
- "Download the PDFs attached to the Client SRL row of the Contracts database."
Install
Install in VS Code Install in Cursor Download for Claude Desktop
With Claude Code:
claude mcp add notion -e NOTION_TOKENS_DIR="<path to Notion tokens folder>" -- npx -y @foreverworks/mcp-notion
Claude Desktop (Settings, Developer, Edit Config) and most other clients read the same command as JSON:
{
"mcpServers": {
"notion": {
"command": "npx",
"args": ["-y", "@foreverworks/mcp-notion"],
"env": {
"NOTION_TOKENS_DIR": "<path to Notion tokens folder>"
}
}
}
}
Each runs the published package on Node 22.19 or newer. VS Code and the Claude Desktop bundle ask for NOTION_TOKENS_DIR as they add the server. Cursor reads it from your environment instead (${env:NOTION_TOKENS_DIR}), so set it before you start Cursor. Configuration says what it holds.
From a clone
git clone https://github.com/foreverworks/mcps.git && cd mcps
npm ci && npm run build
claude mcp add notion -e NOTION_TOKENS_DIR="<path to Notion tokens folder>" -- node "$PWD/servers/notion/dist/index.js"
In a JSON configuration, the command is node and its one argument the full path of servers/notion/dist/index.js. To share one running copy between several clients over HTTP, see docs/deploying.md.
Over HTTP, a deployment that limits its hosts sets
MCP_EGRESS_ALLOW=api.notion.com,prod-files-secure.s3.us-west-2.amazonaws.com,s3.us-west-2.amazonaws.com:
Notion's API and the hosts its files are served from.
Configuration
| Variable | Required | Meaning |
|---|---|---|
NOTION_TOKENS_DIR |
yes, or CREDENTIALS_DIRECTORY |
A folder holding one file per workspace, notion-<label>, each holding that workspace's token and nothing else. |
CREDENTIALS_DIRECTORY |
Read when NOTION_TOKENS_DIR is unset: under systemd, LoadCredential=notion-<label>:<path> puts the same files there, readable by the unit alone. |
|
NOTION_OUTBOX_DIR |
for download_files |
The folder downloaded files are saved in, which the server writes and you read. Unset, download_files answers a config error naming it; every other tool works. |
Each file is a workspace: notion-personal and notion-work are labelled
personal and work, and the server names the labels it found at start. A
label is lower-case letters, digits and hyphens; an empty file is skipped.
There is no default folder, so a test or a hand-run copy cannot reach a
workspace by accident. The files are re-read at most once a minute, and at
once after a refused token, so a new token needs no restart. The variables
every server shares (transport, limits, MCP_FILE_ROOTS for uploads) are in
docs/configuration.md.
Getting a token
For each workspace:
- Open https://www.notion.so/profile/integrations, signed in to that workspace, and create a new internal integration.
- Turn on the capabilities the assistant needs: read, update and insert content for pages and databases, and the comment capabilities for comments. People's emails appear only if it may read user information with email addresses.
- Copy its Internal Integration Secret straight into
notion-<label>in your tokens folder, readable by you alone (mode 0600). Do not paste it into a chat. - Share your pages with the integration, which starts out seeing nothing. In Notion, open each top-level page the assistant should reach, then the ••• menu, Connections, and add the integration. Pages under it inherit the access; a new top-level page must be shared the same way.
A personal access token works in the same file instead (made at https://www.notion.so/developers/tokens, signed in to the workspace, with the Notion API capability). It acts as you: it sees every page you see, with nothing to share, and can create private top-level pages, which an integration cannot. It expires within a year.
First run
Ask your assistant to run Notion's health check. Per workspace, it says
whether the token is accepted, the workspace and integration names, and
whether the integration sees any page yet; if not, do step 4 above.
From a shell, node dist/index.js --health prints one NOTION: line per
workspace and exits 0 when all is well, 1 when a person has to act (a refused
token, a missing capability), and 2 for a warning (an integration that sees
nothing yet, or Notion unwell), which suits a timer.
Tools
read changes nothing; write changes something in a workspace. A tool that takes an id also takes a Notion URL, and finds the workspace itself.
Workspaces and search
| Tool | Access | What it does |
|---|---|---|
list_accounts |
read | The workspaces with a token file, by label. No request to Notion. |
health |
read | Per workspace: the token accepted, the workspace and integration names, whether it sees any page yet. |
list_users |
read | The people (and bots) of every workspace: id, name, and email when the token may read it; narrowed by a word. |
search |
read | Pages and databases by title, in every workspace at once, each hit tagged with its workspace. |
Pages
| Tool | Access | What it does |
|---|---|---|
read_page |
read | A page's properties as plain values and its body as Notion's Markdown. |
read_blocks |
read | The blocks directly inside a page or a block: id, type, plain text. A meeting-notes block names the blocks holding its summary, notes and transcript. |
create_page |
write | A row in a database, checked against its columns (or started from one of its templates), or a child page; either with a Markdown body. |
update_page |
write | A page's properties and icon. |
append_content |
write | Markdown at the end, the start, or after a passage. |
edit_content |
write | Find and replace in a page's Markdown. |
replace_content |
write | A page's whole body; an empty one only with clear. Answers with the page's title and its block count before and after. |
move_page |
write | Under another page or into a database, within one workspace. |
trash_page / restore_page |
write | Into Notion's trash (kept 30 days) and back. A database template only with allow_template. |
Databases
| Tool | Access | What it does |
|---|---|---|
describe_database |
read | A database's tables and columns: types, options, status groups, relation targets, formulas. |
query_database |
read | Rows, filtered by where (typed from the schema) or Notion's own filter, sorted, up to 1,000 in one call. |
count_rows |
read | How many rows match, and per value of a column ("tasks per status"), without sending the rows. |
list_views |
read | A database's saved views: id, name, type, and the filter and sorts saved in each, columns by name. |
query_view |
read | The rows a saved view shows, in its order, filtered exactly as Notion applies it, with the view's total. |
list_database_templates |
read | A database's page templates: their page ids, names and which is the default. |
add_column |
write | A column on a database: a free name, a type and its settings (options, number format, formula, relation), checked against the table first. |
add_options |
write | Options on a select, multi-select or status column; the ones there are kept by id and the column is read back after. |
create_database |
write | A database under a page, with its title column and other columns, each checked against the others first. |
Comments and files
| Tool | Access | What it does |
|---|---|---|
get_comments |
read | A page's open comments. |
add_comment |
write | A comment on a page, or a reply in a discussion. |
upload_file |
write | A file by path, uploaded to a row's files column or into a block at the end of a page's body; answers its size and SHA-256. |
download_files |
read | A page's Notion-hosted files (its files columns and the file blocks of its body) saved to the outbox, each with its SHA-256. |
test/tools.snapshot.json pins what a client lists; a diff there is a
breaking change.
Troubleshooting
health says "the integration sees no page yet", or a call answers
not_shared. Notion answers the same 404 for an id that does not exist and
a page not shared with the integration. Check the id, then share the page
(step 4 of Getting a token); sharing a top-level page
shares everything under it.
auth. The token was refused: revoked, or a personal access token past
its expiry. Put a new one in the notion-<label> file; the next call reads
it.
forbidden. The integration lacks a capability for this call. Turn it on
at https://www.notion.so/profile/integrations (step 2 of
Getting a token).
config. No token files where NOTION_TOKENS_DIR points, an unreadable
one, or one holding more than one token. For download_files, set
NOTION_OUTBOX_DIR.
input from create_page with no parent. Notion refuses a top-level page
from an integration; give a parent page, or use a personal access token. Any
other input names what to fix, and nothing changed.
outcome_unknown. An append, a find-and-replace, a new page or a comment
lost its answer; it is never resent blind. Read the page (or search for a
new one) before writing again: the change may be there.
conflict: someone edited the same object at that moment; read it again,
then retry. rate_limited: Notion still throttles after the retries (180
requests a minute on the Free plan); wait a minute. api: Notion was
unreachable or failed; retry later. With reason: "drift" its answer changed
shape: please report it.
How it works
An id finds its own workspace
An id lives in exactly one workspace, so src/app/locate.ts asks each until
one answers; a 404 from all is not_shared. Every id found, or revealed by a
search or query, is remembered while the process runs, so misses are paid
once. A URL may be on notion.so, notion.site or app.notion.com; ?p=
names a page open over a database.
A database is named by its own id or its data source's (search gives both).
Since API 2025-09-03 a database holds data sources, nearly always one; with
several, source names the table, and omitting it is refused with their
names.
The values an agent sees
| Notion type | Out | In |
|---|---|---|
| title, rich_text | string | string, split into 2000-character runs (Notion's limit), never inside a character |
| number | the JSON number Notion sent | a finite number |
| select, status | option name | an option that exists, matched ignoring case; a new select option only with allow_new_options; a new status never (the API cannot make one) |
| multi_select | names | names, same rule |
| date | {start, end?, timeZone?} |
YYYY-MM-DD, an ISO date-time, or {start, end, time_zone} |
| checkbox | boolean | boolean |
| url, email, phone_number | string | string; empty clears it |
| relation | page ids, all of them | page ids or URLs, 100 at most |
| files | [{name, url, expiresAt?}] |
external URLs; a file of your own goes in with upload_file |
| people, created_by, last_edited_by | {id, name}, the name from the workspace's users when the page gave only the id |
refused |
formula, rollup, created/edited time, unique_id (PREFIX-12) |
the computed value | refused: Notion computes it |
| button | left out: it has no value | refused |
Empty values are left out of a page or row (describe_database lists every
column). A page object lists at most 25 pages of a relation; read_page reads
the rest from the property endpoint (up to 1,000), and a query row lists such
a column under truncated. The server does no arithmetic on numbers.
where
Notion's filter keys a condition by the column's type, which agents get wrong.
where names the column, an op and a value, and takes the type from the
schema:
{"where": [{"property": "Status", "op": "equals", "value": ["In progress", "Blocked"]},
{"property": "Due", "op": "next_week"}],
"match": "all"}
An op the type lacks, or an option that does not exist, is refused with what
exists, before any request. A person column takes a name, an email, a user id
or "me". A name is matched against the workspace's users, exactly first,
then as part of one person's name; one that two people share or nobody has is
refused with who fits. notion_filter takes Notion's own filter object for
what where cannot say, ANDed with it.
Templates
Search returns templates as ordinary pages, and a template shapes every
future row, so list_database_templates names them and trash_page refuses
one without allow_template. create_page with template checks it against
that database's templates. Notion fills the row right after making it, so
read_page a moment later shows the result. Properties given beside a
template are kept; a Markdown body is refused, since it would race the
template's.
Changing a database's columns
add_column, add_options and create_database check the request against
the table as it is, before sending: a name already taken, an option already
there, a setting the type lacks, a formula without its expression, a relation
without its target, more than Notion's 100 options. Notion documents a schema
update's option list as the options to set, so add_options sends back every
current option by id, then the new ones, and reads the column back: an option
that went missing fails the call.
Files
upload_file takes a file by path inside MCP_FILE_ROOTS, or as base64 with
its SHA-256 from a script, never as text a model types. In a row's files
column it joins the files there; otherwise it becomes a block at the end of
the body (an image, a PDF, a video or a sound, by its type). Notion's API
replaces a files column whole and cannot write back a file it hosts, so a
column holding Notion-hosted files needs replace: true, which lets them go;
external links are always kept.
download_files saves a page's Notion-hosted files to NOTION_OUTBOX_DIR:
its files columns and the file, image, PDF, video and audio blocks of its
body, nested ones included (the first 1,000 blocks). Notion's links are signed
URLs valid for an hour, fetched over https without the workspace's token or
following redirects. External links are listed, never fetched, since they can
point anywhere.
Saved views
query_view runs a view as Notion does, quick filters and me included.
Notion answers with page ids only and keeps the result about 15 minutes, so
query_view reads each row itself (25 by default, up to 100 a call), and
nextCursor reads on in the same result. Once that result expires, the tool
says to start again; a row trashed or unshared meanwhile is listed under
unreadable.
People and meeting notes
On an AI meeting-notes page, read_page with include_transcript answers the
summary, notes and transcript as one Markdown text. read_blocks names the
blocks behind them, and on transcriptBlockId reads the transcript line by
line, each with the block id an AI summary's citations link to.
Replacing a whole body
replace_content cannot be undone through the API, and a model slips on both
its arguments: a page id from the wrong row, or a markdown that came out
empty. So an empty body needs clear: true, and the answer gives the page's
title with blocksBefore and blocksAfter, so a wrong page or a body that
shrank to almost nothing shows at once.
The network
- Version
2026-03-11in every request: the one with the Markdown endpoints andin_trash. - Pacing: an integration gets 180 requests in a fixed minute on the Free
plan. A token bucket per workspace (30 at once, then 2.5 a second), shared
by every session the process serves, keeps any minute at 180 or under; the
workspace-wide limit is left to the 429 backoff and
Retry-After. - What is resent: a 429 or a 409
conflict_errorproves nothing happened, so every request repeats. A 5xx or a lost connection proves nothing either way: reads repeat, and so does whatever lands the same twice (a property update, a move, a whole-body replace); the rest fail asoutcome_unknown(notionApi.tsmarks which is which). A 401 is retried once, after the token file is read again. - Caches: a data source's columns for a minute, a workspace's users for five.
Why REST and not the hosted MCP
Notion scopes every token to one workspace, the hosted MCP's included, so no single sign-in reaches several. The hosted MCP's data-source query depends on the plan, it relays tools this repository does not model, and its refresh token can die and need a person. A personal access token also acts as the user, over REST, for a renewal once a year.
scripts/smoke.ts drives the built server read-only, or writes and trashes
one page with --write.
Limitations
- Columns are added, never renamed or deleted; rollups, unique ids and buttons are made in Notion's app. People columns cannot be written.
- A meeting transcript does not say who spoke or when. The API gives it as
plain
paragraphblocks of text (API 2026-03-11,@notionhq/client5.27.0, whose types have no speaker field), though Notion's app shows speaker turns and timestamps. - A page moves only within its workspace; Notion cannot move one between workspaces.
query_databaseanswers at most 1,000 rows a call (thennextCursor), andcount_rowsreads at most Notion's 10,000 per query.- Uploads are single-part, at most 20 MiB a file; downloads stop at 50 MiB.
- Notion-hosted files in a files column cannot be written back, so adding
to such a column needs
replace: true, or the file goes into the body.
Other servers for Notion
Searched 2026-10-15; Notion's MCP page re-read 2026-10-18.
- Notion's hosted server (
mcp.notion.com, Streamable HTTP) needs an OAuth sign-in in a browser and reaches one workspace per connection; only Enterprise workspaces can sign in without a browser, through their identity provider. Its tool set is the widest (views, file uploads, SQL over data sources, Custom Agent sessions). AI search and unlimited SQL over data sources need a Business or Enterprise plan with Notion AI; querying a saved view works on every plan. @notionhq/notion-mcp-server, Notion's open-source server (stdio or HTTP, one token in an environment variable), is no longer actively maintained.notion-mcp-serveron npm, the strongest community server: stdio or HTTP on one token, three tools over 47 operations, typed filters and writes checked against the database's schema, templates, views, file uploads and batched writes.@suekou/mcp-notion-server(stdio, one token) also queries and creates rows from the schema.notion-multi-workspace, the only multi-workspace server found, is archived.
This one exists for several workspaces behind one headless server, each with its own token file: search covers them all at once, and any page or database id or URL finds its own workspace. It also counts matching rows, per value of a column, without sending them, and its whole-body replace refuses an empty body unless asked and answers with the block counts before and after.