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 Desktop (Settings, Developer, Edit Config) and most other clients read the same command as JSON:
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
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
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
Pages
Databases
Comments and files
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
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:
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-11 in every request: the one with the Markdown
endpoints and in_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_error proves 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 as outcome_unknown
(notionApi.ts marks 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
paragraph blocks of text (API 2026-03-11, @notionhq/client
5.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_database answers at most 1,000 rows a call (then nextCursor),
and count_rows reads 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-server on 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.