Skip to content

v3.0.0

Latest

Choose a tag to compare

@github-actions github-actions released this 02 Sep 10:15
· 1 commit to main since this release
ce18d30

Changed

  • Breaking: notion_execute is gone; notion_read and notion_write replace it, and operation is 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's mcp__<server>__<tool> rules, Cursor's per-tool allowlist), and none of them reads the readOnlyHint annotation. notion_read (annotated read-only and idempotent) now runs the read operations and notion_write (annotated destructive) the write operations, with NOTION_CONFIRM_DESTRUCTIVE and the access checks unchanged, so reads can be approved once while writes still ask. Each tool's operation field 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_write is 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_describe keeps its free-string input (the two enums are already the menu) and now returns tool; the notion://operations table 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/server 2.0.0 (McpServer, resources, prompts, the stdio transport) and @modelcontextprotocol/node 2.0.0 (NodeStreamableHTTPServerTransport for the HTTP transport) — replacing the monolithic @modelcontextprotocol/sdk 1.30.0; the tests drive it through @modelcontextprotocol/client 2.0.0. Nothing changes for clients: the same protocol versions are negotiated (2024-11-05 through 2025-11-25; a 2026-07-28 client is still answered with 2025-11-25), and serverInfo, capabilities, the two tools, three resources, four prompts, every HTTP status on /mcp and /health (sessions, 401/403 bearer auth, 403 Host/Origin rebinding checks, 415, 406), and every environment variable are identical — verified by replaying the same handshake against both builds. Two details in tools/list differ: inputSchema.$schema is now JSON Schema 2020-12 (zod 4's native emitter) instead of draft-07, and execution: { "taskSupport": "forbidden" } is no longer emitted (absent means the same thing). Tool and prompt argument schemas are wrapped in z.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-challenge and friends were SDK 1.x transitives and are gone; hono stays as a peer of @modelcontextprotocol/node, and @hono/node-server moves back from 2.0.12 to 1.19.17 because that package pins ^1.19.9 (upstream modelcontextprotocol/typescript-sdk#2548) — npm audit --omit=dev stays clean. Node >=20 and ESM were already required. The transport's enableDnsRebindingProtection / allowedHosts / allowedOrigins options are kept although v2 marks them deprecated: the replacement hostHeaderValidation() / originValidation() guards match hostnames only, which would change what MCP_ALLOWED_HOSTS / MCP_ALLOWED_ORIGINS accept. (PR #82.)
  • @notionhq/client 5.23.2 → 5.26.0. 5.24 widened the SDK's response type with an optional web-stream body (getReader()) for its new sessions.stream() SSE API, so the node-fetch-based proxy adapter in src/services/notion.ts no longer satisfied SupportedFetch and the build failed. The adapter now returns the SDK's expected shape and exposes the body lazily through Readable.toWeb, so HTTPS_PROXY keeps working. 5.25.1 also tightened isFullDatabase to require title, 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.yaml are gone. The Docker MCP catalog submission is tracked as docker/mcp-registry#4227. (PR #81.)
  • Manifests must agree. tests/manifests.test.ts fails the build when package.json, server.json (both version fields) and gemini-extension.json disagree on the version, and checks the MCPB manifest's privacy_policies URL and sensitive notion_token setting. (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-alpine to 25-alpine was a downgrade. Digest refreshes continue; the move to 26-alpine happens by hand once it enters LTS in October 2026. Closes the loop on #67. (PR #75.)
  • notion_describe is a third smaller. The 48 schemas summed to 183 KB; they now sum to 121 KB. update_block and batch_mixed_blocks drop 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 $defs once 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_error used to answer with the whole operation schema; the fix now says to read the message for the field it names (path points at it when present) and where to find a working example. where_compile_error names the data source's properties.
  • Runtime dependency bumps: hono 4.12.33 → 4.13.5 (#70), ip-address 10.2.0 → 10.4.0 (#65; since removed from the tree by the SDK v2 migration), node:24-alpine base image digest 156b55f → e67514e (#77).
  • Dev toolchain bumps: typescript 6.0.3 → 7.0.2 (#43), @types/node 26.1.1 → 26.3.0 and vitest 4.1.10 → 4.1.11 (#76).
  • CI action bumps: docker/setup-buildx-action v4.2.0 → v4.3.0 (#73). Pinned by commit SHA.

Added

  • Plain property values on database rows. create_page, set_page_property and set_page_properties took 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 (one dataSources.retrieve, cached for 5 minutes, refreshed by update_data_source) and types each value by the property's type; null clears; title addresses 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 unknown status option 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 returns not_a_database_page. create_page with a database_id parent resolves a single-source database to its data source itself (multi_source_database otherwise). get_data_source and search_pages now list a select / multi_select / status property'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 omit type when the body key names it: { Status: { select: { options: [...] } } }.
  • Schema-aware where and a sort shorthand on query_database. where used to guess a property's type from the value's shape — a string meant select, so { Status: "Done" } on a status property 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, or equals: false on a checkbox) and operator objects (__type still overrides), and an unknown property name fails locally with the data source's property list instead of an opaque Notion error. sorts accepts "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 the where_compile_error fix says what to check.
  • Tables in markdown. A GFM table in markdown (on create_page, append_blocks, update_block, update_page_markdown) becomes a Notion table block with table_row children — 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 a table_row's cells as plain strings and a table's table_width.
  • Unknown fields warn instead of vanishing. z.object strips keys it does not know, so a misspelt or misplaced field (page_size on 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 carries warnings: ["Ignored unknown field \"bogus\". create_page accepts: parent, title, properties, markdown, children, template, icon, cover, verbose."]; a batch item gets its own warnings, and an unknown envelope field (parallel: true) a batch-level one. Loose schemas and unions never warn. The server instructions say that a result's warnings lists ignored fields.
  • Partial block updates. update_block's data demanded the whole block body with type, object, id, has_children and more — 28 KB of notion_describe for a checkbox — while Notion itself accepts { to_do: { checked: true } }. data is 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 the update arm of batch_mixed_blocks, and - [x] text markdown sets both the text and the checked state.
  • object on search results. A slim page and a slim database look alike, and a database id is not a valid create_page parent; every search_pages result now carries object: "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_describe for 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; --write adds every write operation inside one page created under NOTION_PAGE_ID (markdown round-trip, batch mode, a database with data source and views, null deleting a property, comments, upload_file with attach_to followed by get_file_url and get_image under NOTION_FILE_URLS=ref, move/archive/trash/restore, delete and restore of the database and data source) and trashes that page at the end (--keep leaves 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 show notifications/message entries 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 the logging capability; every line still goes to stderr, word for word as before, and is also forwarded as a notifications/message (logger notion-mcp-server, data: { message, … }). It honours logging/setLevel, with info as the default when a client never sets one (the SDK's own filter would send everything, debug included, until then), and at debug adds one line per notion_execute call 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 before connect() or after the transport closed just writes stderr. (PR #86.)
  • NOTION_CONFIRM_DESTRUCTIVE: the user confirms a destructive operation before it runs. Blocking the destructive group is all-or-nothing, and the server instructions could only ask the model to check with the user before trashing something. With NOTION_CONFIRM_DESTRUCTIVE=true (or 1; default off), notion_execute asks the human itself, through MCP elicitation, before dispatching any operation the registry marks destructive: truearchive_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_source with in_trash: false) and a batch_mixed_blocks call with no delete entry never prompt, and the access checks still run first, so a blocked operation returns operation_not_allowed without asking. Decline, cancel or answer no and the call returns confirmation_declined with a fix saying not to retry; the server instructions repeat that while the flag is on. A client that has not declared the elicitation capability gets confirmation_unavailable rather than a silent run. Documented in the README's env-var table and Restricting operations section and in llms-install.md.
  • --version and --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 prints Unknown option: X plus 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 built build/index.js for 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 HEALTHCHECK against /health would have marked every stdio container unhealthy; the Dockerfile instead carries the line commented out, and the README's HTTP-transport section shows the docker run --health-cmd and Compose healthcheck: equivalents for MCP_TRANSPORT=http deployments. The README's Docker HTTP example also gains HOST=0.0.0.0 (and MCP_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_url and 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, and NOTION_PAGE_ID — now normalizes a Notion URL, a notion:// deep link, a dashed uuid or a bare 32-hex id to the dashed uuid the API wants. A block link's #fragment is used for block_id/after fields and a database link's ?v= for view_id fields, so a view link passed as database_id still means the database. Anything else passes through unchanged and fails exactly as before. Implemented with z.preprocess rather than .transform so the emitted JSON Schema keeps each field's type and description in notion_describe. The notion_execute description and the README say so. (PR #47.)
  • attach_to on upload_file. upload_file returned a file_upload_id and nothing said what to do with it; placing the file took a second call with raw children JSON whose shape appeared in no schema or example. attach_to: { block_id, caption?, position?, after? } — the same placement fields as append_blocks — appends the block in the same call. The block type follows the content type: image/*image, video/*video, audio/*audio, application/pdfpdf, anything else → file. The result then carries block_id and block_type next to file_upload_id; if the append fails after the upload, the error names the file_upload_id and the exact append_blocks call that places it, so the file is never uploaded twice. (PR #48.)
  • NOTION_FILE_URLS=ref, get_file_url and get_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. With NOTION_FILE_URLS=ref, slim responses carry notion-file:block/<id> or notion-file:page/<id>/<prop>/<index> instead; get_file_url re-reads the source object for a fresh signed URL, so the server caches nothing. The default stays full, and an external URL is never rewritten. get_image returns the bytes as MCP image content (every other operation returns text, so a model could not see an image at all). It accepts only a notion-file: ref or a block id and fetches only the signed URL Notion returns for it — never a caller-supplied URL — refuses non-https URLs, checks content-length and reads the body in chunks with a 5 MB cap, and only accepts image/* content types. get_block on 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_database and delete_data_source. update_database and update_data_source accepted in_trash, so an operation declared access: "write" with no destructive flag could trash a database and every page in it, and NOTION_BLOCKED_OPERATIONS=destructive did not stop it — the README listed this under Limitations. Both trash paths now live in their own operations, marked destructive: true, so the destructive group token, NOTION_READ_ONLY and the allow/block lists cover them; in_trash: false restores, and the deprecated archived alias is routed into in_trash. update_database and update_data_source keep in_trash/archived in their schemas but reject them with a trash_moved envelope pointing at the new operation (mirroring properties_moved), because z.object strips unknown keys and a silently ignored trash call is worse than an error. Both accept a Notion URL as database_id/data_source_id. (PR #53; URL support in #47.)
  • file_upload accepted wherever Notion accepts it. The shared file schema only had the external variant, so a file_upload_id from upload_file could not reach an image/video/audio/pdf/file block, a page or database cover, or a files property. It is now a discriminated union on type (external | file_upload); a files property value takes both arms, with name optional on an uploaded entry (Notion already knows its filename) and the optional type tag Notion echoes back accepted instead of stripped. Verified against api.notion.com. (PR #46.)
  • null deletes a property on update_data_source. Notion removes a data source property when its definition is null (PATCH /data_sources/:id { properties: { Old: null } }), and the schema rejected it. The nullable lives only where the API takes it: update_data_source.properties values may be null (the emitted JSON Schema carries the variant and a description saying what it does, and the handler forwards it untouched), while create_database's initial_data_source still refuses a null Notion would reject. update_database keeps a nullable value too, so a { Name: null } attempt there reaches the properties_moved redirect instead of dying in validation. (Thanks @FrancoMeneses — PR #80.)
  • Real server instructions. The two-line placeholder instructions string became a 1.5 KB getting-started text: search with search_pages/query_database, read with get_page_markdown (or get_page with include_properties: true), write with markdown on create_page/append_blocks, the data_source_id rule, the batch payload shape, the error → fix retry loop, when to call notion_describe, and which operations are destructive. When NOTION_READ_ONLY or an allowlist has disabled operations, a trailing line says how many are enabled and points at the notion://operations resource. 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.json at the repo root makes the server installable with gemini extensions install https://github.com/awkoy/notion-mcp-serverNOTION_TOKEN is a sensitive keychain setting, NOTION_PAGE_ID optional. 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; what get_image and upload_file will and will not fetch and a privacy_policies entry 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) spawns build/index.js, and publish-npm.yml ran npm test straight after npm ci, so the first v3.0.0 publish failed in the test step before npm publish ran; ci.yml had 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 on main.
  • get_image and upload_file's url source ignored HTTPS_PROXY. The Notion SDK's calls went through node-fetch with an HttpsProxyAgent whenever HTTPS_PROXY / HTTP_PROXY (or their lowercase forms) is set, but the two direct downloads — the signed image URL in get_image and the source: { type: "url" } fetch in upload_file — used the global fetch, so behind a corporate proxy both failed while every other operation worked. Both now go through one exported proxyAwareFetch helper that the SDK adapter shares, and the proxy agent is built once per proxy URL instead of once per request. get_image keeps every guard, now on node-fetch's Node stream: https-only, image/* content types, the content-length check before a byte of body is read, and the chunked read that stops and destroys the stream past 5 MB. Tests mock node-fetch and assert that an agent for the proxy in HTTPS_PROXY reaches it, and that none does when the variable is unset.
  • create_page property errors were 4× too big. A record schema keeps its value schema under additionalProperties and has no properties, 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: a create_page property 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_describe hands example/example_batch to the model as the canonical call shape, but nothing checked them; the update_data_source example used type: "status", which the schema (and the Notion API) rejects for new property definitions. The example now creates a select property, and tests/examples.test.ts parses every registered operation's example and each exampleBatch item 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