Changed
- Breaking:
notion_executeis gone;notion_readandnotion_writereplace it, andoperationis an enum. One tool ran every operation, so a client could only allow or prompt for all of Notion at once: MCP clients grant permissions by tool name (Claude Code'smcp__<server>__<tool>rules, Cursor's per-tool allowlist), and none of them reads thereadOnlyHintannotation.notion_read(annotated read-only and idempotent) now runs the read operations andnotion_write(annotated destructive) the write operations, withNOTION_CONFIRM_DESTRUCTIVEand the access checks unchanged, so reads can be approved once while writes still ask. Each tool'soperationfield is an enum of exactly the operations enabled on this server instead of a free string: the menu ships inside the tool list, a client can validate a call before sending it, a disabled operation cannot be named at all, and when no write operation is enabled (NOTION_READ_ONLY, or an allowlist of reads)notion_writeis not advertised. A name sent to the wrong tool fails validation in one round-trip with a message that says which tool runs it; an unknown name lists the tool's operations.notion_describekeeps its free-string input (the two enums are already the menu) and now returnstool; thenotion://operationstable gained a Tool column; the four prompts and the per-call debug log line name the tool that ran. Same payloads, same responses, same environment variables — only the tool name changes, and modern clients rediscover tools on reconnect. See MIGRATION.md. - MCP TypeScript SDK v2. The server now builds on the split SDK 2.0 packages —
@modelcontextprotocol/server2.0.0(McpServer, resources, prompts, the stdio transport) and@modelcontextprotocol/node2.0.0(NodeStreamableHTTPServerTransportfor the HTTP transport) — replacing the monolithic@modelcontextprotocol/sdk1.30.0; the tests drive it through@modelcontextprotocol/client2.0.0. Nothing changes for clients: the same protocol versions are negotiated (2024-11-05through2025-11-25; a2026-07-28client is still answered with2025-11-25), andserverInfo, capabilities, the two tools, three resources, four prompts, every HTTP status on/mcpand/health(sessions,401/403bearer auth,403Host/Origin rebinding checks,415,406), and every environment variable are identical — verified by replaying the same handshake against both builds. Two details intools/listdiffer:inputSchema.$schemais now JSON Schema 2020-12 (zod 4's native emitter) instead of draft-07, andexecution: { "taskSupport": "forbidden" }is no longer emitted (absent means the same thing). Tool and prompt argument schemas are wrapped inz.object(), since v2 deprecates raw shapes. The production dependency tree shrinks from 167 to 84 packages —express,express-rate-limit,ip-address,ajv,cors,jose,pkce-challengeand friends were SDK 1.x transitives and are gone;honostays as a peer of@modelcontextprotocol/node, and@hono/node-servermoves back from2.0.12to1.19.17because that package pins^1.19.9(upstream modelcontextprotocol/typescript-sdk#2548) —npm audit --omit=devstays clean. Node>=20and ESM were already required. The transport'senableDnsRebindingProtection/allowedHosts/allowedOriginsoptions are kept although v2 marks them deprecated: the replacementhostHeaderValidation()/originValidation()guards match hostnames only, which would change whatMCP_ALLOWED_HOSTS/MCP_ALLOWED_ORIGINSaccept. (PR #82.) @notionhq/client5.23.2 → 5.26.0. 5.24 widened the SDK's response type with an optional web-streambody(getReader()) for its newsessions.stream()SSE API, so thenode-fetch-based proxy adapter insrc/services/notion.tsno longer satisfiedSupportedFetchand the build failed. The adapter now returns the SDK's expected shape and exposes the body lazily throughReadable.toWeb, soHTTPS_PROXYkeeps working. 5.25.1 also tightenedisFullDatabaseto requiretitle, which the test doubles now carry. (PR #76, supersedes #74.)- Smithery listing removed. Smithery dropped stdio hosting in 2025-09 and the listing 404s, so the README badge and
smithery.yamlare gone. The Docker MCP catalog submission is tracked as docker/mcp-registry#4227. (PR #81.) - Manifests must agree.
tests/manifests.test.tsfails the build whenpackage.json,server.json(both version fields) andgemini-extension.jsondisagree on the version, and checks the MCPB manifest'sprivacy_policiesURL and sensitivenotion_tokensetting. (PR #81.) - Dependabot skips Node major bumps for the Docker base image. Odd Node majors are 6-month non-LTS lines (25 reached EOL in June 2026), so a Dependabot PR moving
24-alpineto25-alpinewas a downgrade. Digest refreshes continue; the move to26-alpinehappens by hand once it enters LTS in October 2026. Closes the loop on #67. (PR #75.) notion_describeis a third smaller. The 48 schemas summed to 183 KB; they now sum to 121 KB.update_blockandbatch_mixed_blocksdrop from 28 KB to under 2 KB each (the partial-update shape above), the shared rich-text, parent, icon, file, property-value, colour, language, mention, property-definition and enum shapes are hoisted into$defsonce per schema instead of being repeated inline, and block requests no longer advertise the read-only echo fields (object,id,created_time,has_children, …) that Notion ignores on write. Every example still parses against its schema.- Errors say what to fix. A Notion
validation_errorused to answer with the whole operation schema; thefixnow says to read the message for the field it names (pathpoints at it when present) and where to find a working example.where_compile_errornames the data source's properties. - Runtime dependency bumps:
hono4.12.33 → 4.13.5(#70),ip-address10.2.0 → 10.4.0(#65; since removed from the tree by the SDK v2 migration),node:24-alpinebase image digest156b55f → e67514e(#77). - Dev toolchain bumps:
typescript6.0.3 → 7.0.2(#43),@types/node26.1.1 → 26.3.0andvitest4.1.10 → 4.1.11(#76). - CI action bumps:
docker/setup-buildx-actionv4.2.0 → v4.3.0(#73). Pinned by commit SHA.
Added
- Plain property values on database rows.
create_page,set_page_propertyandset_page_propertiestook only Notion's typed shapes —{ status: { name: "Done" } },{ date: { start: "2026-10-01" } },{ multi_select: [{ name: "a" }] }— so a model had to know or look up the property type before it could write a row, and a wrong guess cost a round-trip. A row's properties now take plain values:{ Status: "Done", "Due Date": "2026-10-01", Tags: ["a", "b"], Done: true, Score: 42, Notes: null, Owner: "<user-id>", Attachments: ["https://…/chart.png"] }. When a payload contains a plain value the server reads the row's data source schema (onedataSources.retrieve, cached for 5 minutes, refreshed byupdate_data_source) and types each value by the property's type;nullclears;titleaddresses the title property whatever it is called; a name that differs only in case is corrected with a warning; an unknown property is refused with the valid names, an unknownstatusoption with the valid options, and a value of the wrong kind or a read-only property (formula, rollup, created_time, …) with a message that says so. Typed values still pass through untouched and cost no extra request, and a plain value on a page that is not a row returnsnot_a_database_page.create_pagewith adatabase_idparent resolves a single-source database to its data source itself (multi_source_databaseotherwise).get_data_sourceandsearch_pagesnow list aselect/multi_select/statusproperty's options inline ("select: Low | Medium | High", first 30) and a relation's target data source, so one read is enough to write a row or a filter. Property definitions (create_database,update_data_source) may omittypewhen the body key names it:{ Status: { select: { options: [...] } } }. - Schema-aware
whereand a sort shorthand onquery_database.whereused to guess a property's type from the value's shape — a string meantselect, so{ Status: "Done" }on astatusproperty or{ Name: { contains: "x" } }on a title compiled to a filter Notion rejected. When the data source schema is available the compiler now uses the declared type for plain values,null(is_empty, orequals: falseon a checkbox) and operator objects (__typestill overrides), and an unknown property name fails locally with the data source's property list instead of an opaque Notion error.sortsaccepts"Due Date"/"-Due Date"(descending) and"created_time"/"last_edited_time"next to the object form. The description and example lead with the shorthand, and thewhere_compile_errorfix says what to check. - Tables in markdown. A GFM table in
markdown(oncreate_page,append_blocks,update_block,update_page_markdown) becomes a Notiontableblock withtable_rowchildren — first row as the column header, short rows padded to the table's width, inline formatting kept in the cells — instead of being dropped. Slim block responses show atable_row'scellsas plain strings and atable'stable_width. - Unknown fields warn instead of vanishing.
z.objectstrips keys it does not know, so a misspelt or misplaced field (page_sizeon the wrong operation,bogus: 1) silently did nothing. Rejecting it would cost a round-trip when everything else was right, so the call runs and the successful result carrieswarnings: ["Ignored unknown field \"bogus\". create_page accepts: parent, title, properties, markdown, children, template, icon, cover, verbose."]; a batch item gets its ownwarnings, and an unknown envelope field (parallel: true) a batch-level one. Loose schemas and unions never warn. The server instructions say that a result'swarningslists ignored fields. - Partial block updates.
update_block'sdatademanded the whole block body withtype,object,id,has_childrenand more — 28 KB ofnotion_describefor a checkbox — while Notion itself accepts{ to_do: { checked: true } }.datais now{ type?, <type>: { …fields to change } }: the type is inferred from the single body key, only the fields given are sent, and a payload with no body key is refused with the shape in the message. The same form works for theupdatearm ofbatch_mixed_blocks, and- [x] textmarkdown sets both the text and the checked state. objecton search results. A slim page and a slim database look alike, and a database id is not a validcreate_pageparent; everysearch_pagesresult now carriesobject: "page" | "database" | "data_source".- Live end-to-end smoke test.
npm run e2e(scripts/e2e.mjs) starts the built server over stdio and drives it against a real workspace with the token in.env: the MCP handshake, the three resources and four prompts,notion_describefor every operation, a malformed call to check that the error carries the schema and an example, a Notion URL passed as an id, and every read operation;--writeadds every write operation inside one page created underNOTION_PAGE_ID(markdown round-trip, batch mode, a database with data source and views,nulldeleting a property, comments,upload_filewithattach_tofollowed byget_file_urlandget_imageunderNOTION_FILE_URLS=ref, move/archive/trash/restore, delete and restore of the database and data source) and trashes that page at the end (--keepleaves it). The unit tests all run against a mocked Notion client, so until now nothing exercised the real API end to end; the 2.14.0 build passed this run with 76/76 checks and 48/48 operations. Not part of CI. - Server logs reach the MCP client. The server only ever logged with
console.error, and most clients hide a server's stderr — VS Code, MCP Inspector and Claude Desktop shownotifications/messageentries in their own log views instead — so the startup banner, the operation-access summary, the Notion auth probe and HTTP or auth failures were invisible there. The server now declares theloggingcapability; every line still goes to stderr, word for word as before, and is also forwarded as anotifications/message(loggernotion-mcp-server,data: { message, … }). It honourslogging/setLevel, withinfoas the default when a client never sets one (the SDK's own filter would send everything,debugincluded, until then), and atdebugadds one line pernotion_executecall with the operation, batch size, duration and outcome — never the payload or page content. On stdio the process-level lines go to the one connected client; on HTTP, where there is one server per session, only a session's own per-call lines reach it, so no session sees another's traffic. A log call beforeconnect()or after the transport closed just writes stderr. (PR #86.) NOTION_CONFIRM_DESTRUCTIVE: the user confirms a destructive operation before it runs. Blocking thedestructivegroup is all-or-nothing, and the server instructions could only ask the model to check with the user before trashing something. WithNOTION_CONFIRM_DESTRUCTIVE=true(or1; default off),notion_executeasks the human itself, through MCP elicitation, before dispatching any operation the registry marksdestructive: true—archive_page/trash_page,delete_block,batch_mixed_blocks,delete_database,delete_data_source,delete_view,delete_comment. The prompt is a yes/no form that names the operation and its target: the page, database, data source or block title when one retrieve (bounded to 5 s, any failure swallowed) can fetch it, otherwise the id, and for a batch how many items. Read operations, non-destructive writes, restores (delete_database/delete_data_sourcewithin_trash: false) and abatch_mixed_blockscall with nodeleteentry never prompt, and the access checks still run first, so a blocked operation returnsoperation_not_allowedwithout asking. Decline, cancel or answer no and the call returnsconfirmation_declinedwith afixsaying not to retry; the server instructions repeat that while the flag is on. A client that has not declared the elicitation capability getsconfirmation_unavailablerather than a silent run. Documented in the README's env-var table and Restricting operations section and inllms-install.md.--versionand--help.notion-mcp-server --version(or-v) prints the package version — exactly the version and a newline, so it can be captured — and exits 0 without starting a transport or touching the network;--help(or-h) prints a usage summary: the two transports, every environment variable on one line each, and links to the README sections. Any other argument printsUnknown option: Xplus the usage to stderr and exits 2 — the server takes no other flags, so a typo no longer silently starts a stdio server that sits waiting on stdin. A test spawns the builtbuild/index.jsfor each case and checks the help text against the README's variable tables.- Docker health check, opt-in. The image starts in stdio mode, where nothing listens, so a built-in
HEALTHCHECKagainst/healthwould have marked every stdio container unhealthy; theDockerfileinstead carries the line commented out, and the README's HTTP-transport section shows thedocker run --health-cmdand Composehealthcheck:equivalents forMCP_TRANSPORT=httpdeployments. The README's Docker HTTP example also gainsHOST=0.0.0.0(andMCP_AUTH_TOKEN), without which the published port is unreachable from outside the container. - Every id field takes a Notion URL. A page URL is what the Notion app puts on the clipboard (Share → Copy link), and the API answered one with
invalid_request_urland no hint about what to send. Every id field —page_id,block_id,database_id,data_source_id,view_id,after,create_page's parent, relation and people property values, user/page/database mention ids, andNOTION_PAGE_ID— now normalizes a Notion URL, anotion://deep link, a dashed uuid or a bare 32-hex id to the dashed uuid the API wants. A block link's#fragmentis used forblock_id/afterfields and a database link's?v=forview_idfields, so a view link passed asdatabase_idstill means the database. Anything else passes through unchanged and fails exactly as before. Implemented withz.preprocessrather than.transformso the emitted JSON Schema keeps each field's type and description innotion_describe. Thenotion_executedescription and the README say so. (PR #47.) attach_toonupload_file.upload_filereturned afile_upload_idand nothing said what to do with it; placing the file took a second call with rawchildrenJSON whose shape appeared in no schema or example.attach_to: { block_id, caption?, position?, after? }— the same placement fields asappend_blocks— appends the block in the same call. The block type follows the content type:image/*→image,video/*→video,audio/*→audio,application/pdf→pdf, anything else →file. The result then carriesblock_idandblock_typenext tofile_upload_id; if the append fails after the upload, the error names thefile_upload_idand the exactappend_blockscall that places it, so the file is never uploaded twice. (PR #48.)NOTION_FILE_URLS=ref,get_file_urlandget_image. A signed Notion file URL runs about 1,650 characters, is re-signed on every read and expires in an hour — roughly 500 tokens per file that cannot be stored, and a page holding an image could not reuse a cached prompt prefix across turns. WithNOTION_FILE_URLS=ref, slim responses carrynotion-file:block/<id>ornotion-file:page/<id>/<prop>/<index>instead;get_file_urlre-reads the source object for a fresh signed URL, so the server caches nothing. The default staysfull, and an external URL is never rewritten.get_imagereturns the bytes as MCP image content (every other operation returns text, so a model could not see an image at all). It accepts only anotion-file:ref or a block id and fetches only the signed URL Notion returns for it — never a caller-supplied URL — refuses non-httpsURLs, checkscontent-lengthand reads the body in chunks with a 5 MB cap, and only acceptsimage/*content types.get_blockon an image block drops from 1,723 to 145 characters with refs on. Both operations are read-only and documented in a new README → Files section. (PR #50.)delete_databaseanddelete_data_source.update_databaseandupdate_data_sourceacceptedin_trash, so an operation declaredaccess: "write"with no destructive flag could trash a database and every page in it, andNOTION_BLOCKED_OPERATIONS=destructivedid not stop it — the README listed this under Limitations. Both trash paths now live in their own operations, markeddestructive: true, so thedestructivegroup token,NOTION_READ_ONLYand the allow/block lists cover them;in_trash: falserestores, and the deprecatedarchivedalias is routed intoin_trash.update_databaseandupdate_data_sourcekeepin_trash/archivedin their schemas but reject them with atrash_movedenvelope pointing at the new operation (mirroringproperties_moved), becausez.objectstrips unknown keys and a silently ignored trash call is worse than an error. Both accept a Notion URL asdatabase_id/data_source_id. (PR #53; URL support in #47.)file_uploadaccepted wherever Notion accepts it. The shared file schema only had theexternalvariant, so afile_upload_idfromupload_filecould not reach an image/video/audio/pdf/file block, a page or database cover, or afilesproperty. It is now a discriminated union ontype(external|file_upload); afilesproperty value takes both arms, withnameoptional on an uploaded entry (Notion already knows its filename) and the optionaltypetag Notion echoes back accepted instead of stripped. Verified against api.notion.com. (PR #46.)nulldeletes a property onupdate_data_source. Notion removes a data source property when its definition isnull(PATCH /data_sources/:id { properties: { Old: null } }), and the schema rejected it. The nullable lives only where the API takes it:update_data_source.propertiesvalues may benull(the emitted JSON Schema carries the variant and a description saying what it does, and the handler forwards it untouched), whilecreate_database'sinitial_data_sourcestill refuses anullNotion would reject.update_databasekeeps a nullable value too, so a{ Name: null }attempt there reaches theproperties_movedredirect instead of dying in validation. (Thanks @FrancoMeneses — PR #80.)- Real server instructions. The two-line placeholder
instructionsstring became a 1.5 KB getting-started text: search withsearch_pages/query_database, read withget_page_markdown(orget_pagewithinclude_properties: true), write withmarkdownoncreate_page/append_blocks, thedata_source_idrule, the batch payload shape, the error →fixretry loop, when to callnotion_describe, and which operations are destructive. WhenNOTION_READ_ONLYor an allowlist has disabled operations, a trailing line says how many are enabled and points at thenotion://operationsresource. Claude Code and Cursor show the model only tool names and this text until a tool is needed, and Claude Code truncates it at 2 KB, so a test keeps it under 2,000 bytes with no indented lines. (PR #81.) - Gemini CLI extension. A
gemini-extension.jsonat the repo root makes the server installable withgemini extensions install https://github.com/awkoy/notion-mcp-server—NOTION_TOKENis a sensitive keychain setting,NOTION_PAGE_IDoptional. README quick-start entry. (PR #81.) - Privacy policy. A README → Privacy section — the server talks only to
api.notion.com, over HTTPS, with the token you configure; no telemetry, no analytics, no server of ours in the path; whatget_imageandupload_filewill and will not fetch and aprivacy_policiesentry in the MCPB manifest pointing at it — the Claude Desktop extension directory rejects submissions without one. (PR #81.)
Fixed
- The npm release workflow tested before building.
tests/cli.test.ts(new in this release) spawnsbuild/index.js, andpublish-npm.ymlrannpm teststraight afternpm ci, so the firstv3.0.0publish failed in the test step beforenpm publishran;ci.ymlhad built first all along. The workflow now builds before it tests, in the same order as CI, and the 3.0.0 package was published by re-running it onmain. get_imageandupload_file'surlsource ignoredHTTPS_PROXY. The Notion SDK's calls went through node-fetch with anHttpsProxyAgentwheneverHTTPS_PROXY/HTTP_PROXY(or their lowercase forms) is set, but the two direct downloads — the signed image URL inget_imageand thesource: { type: "url" }fetch inupload_file— used the globalfetch, so behind a corporate proxy both failed while every other operation worked. Both now go through one exportedproxyAwareFetchhelper that the SDK adapter shares, and the proxy agent is built once per proxy URL instead of once per request.get_imagekeeps every guard, now on node-fetch's Node stream: https-only,image/*content types, thecontent-lengthcheck before a byte of body is read, and the chunked read that stops and destroys the stream past 5 MB. Tests mocknode-fetchand assert that an agent for the proxy inHTTPS_PROXYreaches it, and that none does when the variable is unset.create_pageproperty errors were 4× too big. A record schema keeps its value schema underadditionalPropertiesand has noproperties, and a page property map is one, so slicing an error's schema to["properties", "Tags"]stopped at the map and the union summarizer then found nothing to collapse — both returned the whole map. The slice now descends through the record and the summary runs underneath it: acreate_pageproperty error drops from 5,762 to 1,323 characters, and what it returns is the union of property value shapes instead of every branch in full. (PR #45.)update_data_source's own example did not parse.notion_describehandsexample/example_batchto the model as the canonical call shape, but nothing checked them; theupdate_data_sourceexample usedtype: "status", which the schema (and the Notion API) rejects for new property definitions. The example now creates aselectproperty, andtests/examples.test.tsparses every registered operation'sexampleand eachexampleBatchitem against its schema so this cannot regress. (PR #79.)- Stale operation-count anchor. The 2.8.0 entry's link to the README operations menu pointed at the 45-operation heading; it is now the 47-operation one.
Full changelog: https://github.com/awkoy/notion-mcp-server/blob/main/CHANGELOG.md#300--2026-09-02 · Migration: https://github.com/awkoy/notion-mcp-server/blob/main/MIGRATION.md