notion-mcp-server 2.4.4 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -209,6 +209,8 @@ If you ran a v1.x setup, **nothing in your environment needs to change**. Both e
209
209
  | `NOTION_TOKEN` | ✅ Required | Accepts **PATs** (`ntn_…`, recommended) and **Internal Integration secrets** (`secret_…` or `ntn_…`, legacy). Identical handling. |
210
210
  | `NOTION_PAGE_ID` | ✅ Optional | Still works as the default parent page for `create_page` / `create_database` when no `parent` is passed. v2 added a clean `missing_parent` validation error instead of v1's crash when neither is provided. |
211
211
  | `NOTION_RATE_LIMIT` | ✅ New, optional | Requests per second for the shared limiter. Defaults to `3` (Notion's documented per-integration limit). |
212
+ | `NOTION_ALLOWED_OPERATIONS` | ✅ New, optional | Comma-separated allowlist of operations or group presets (`read`, `write`, `destructive`, plus per-domain groups `pages`, `blocks`, `databases`, `data_sources`, `comments`, `users`, `files`). Unset ⇒ all operations enabled. See [Restricting operations](#restricting-operations). |
213
+ | `NOTION_BLOCKED_OPERATIONS` | ✅ New, optional | Comma-separated blocklist (same token vocabulary). Applied after the allowlist, so a blocked operation is always disabled. |
212
214
  | `NOTION_DAILY_LOG_PAGE_ID` | ✅ Optional | Used only by the daily-log MCP prompt. Ignore if you don't call that prompt. |
213
215
 
214
216
  The only v2 break is the **tool surface itself** — v1's `notion_pages`, `notion_blocks`, `notion_database`, `notion_comments`, `notion_users` are replaced by `notion_execute` and `notion_describe`. Modern MCP clients (Claude Code, Cursor, Claude Desktop) rediscover tools at startup, so they pick up the new surface automatically. If your client hard-codes the v1 tool names, see [MIGRATION.md](./MIGRATION.md) for the rename map.
@@ -219,6 +221,93 @@ A typical v1.x invocation continues to work unchanged:
219
221
  NOTION_TOKEN=secret_xxx NOTION_PAGE_ID=abc123... node build/index.js
220
222
  ```
221
223
 
224
+ ### Restricting operations
225
+
226
+ By default every operation is available. To limit what an agent can do, set
227
+ `NOTION_ALLOWED_OPERATIONS` (an allowlist) and/or `NOTION_BLOCKED_OPERATIONS` (a
228
+ blocklist). Each is a comma-separated list of **tokens**, where a token is either a
229
+ **group preset** or an exact **operation name**.
230
+
231
+ **Group presets** (one token expands to many operations):
232
+
233
+ | Token | Expands to |
234
+ | --- | --- |
235
+ | `read` | every non-mutating operation |
236
+ | `write` | every mutating operation |
237
+ | `destructive` | operations whose purpose is removal (marked † below) |
238
+ | `pages` `blocks` `databases` `data_sources` `comments` `users` `files` | every operation in that resource family (read **and** write) |
239
+
240
+ **All operations** — use any name directly for a precise allow/blocklist. († = also in the `destructive` group.)
241
+
242
+ | Domain | Read | Write |
243
+ | --- | --- | --- |
244
+ | `pages` | `search_pages` `get_page` `get_page_markdown` | `create_page` `set_page_title` `set_page_property` `set_page_properties` `update_page_markdown` `move_page` `restore_page` `archive_page`† `trash_page`† |
245
+ | `blocks` | `get_block` `get_block_children` | `append_blocks` `update_block` `delete_block`† `batch_mixed_blocks`† |
246
+ | `databases` | `query_database` | `create_database` `update_database` |
247
+ | `data_sources` | `list_data_sources` `get_data_source` | `update_data_source` |
248
+ | `comments` | `list_comments` `get_comment` | `add_page_comment` `add_discussion_comment` `update_comment` `delete_comment`† |
249
+ | `users` | `list_users` `get_user` `get_bot_user` `get_self` | — |
250
+ | `files` | `list_file_uploads` `get_file_upload` | `upload_file` |
251
+
252
+ Read-only deployment (the most common case):
253
+
254
+ ```json
255
+ {
256
+ "mcpServers": {
257
+ "notion": {
258
+ "command": "npx",
259
+ "args": ["-y", "notion-mcp-server"],
260
+ "env": {
261
+ "NOTION_TOKEN": "ntn_paste_your_token_here",
262
+ "NOTION_ALLOWED_OPERATIONS": "read"
263
+ }
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ Allow everything except destructive operations:
270
+
271
+ ```json
272
+ { "env": { "NOTION_BLOCKED_OPERATIONS": "destructive" } }
273
+ ```
274
+
275
+ Mix presets and individual ops (read everything, plus append blocks and comments):
276
+
277
+ ```json
278
+ { "env": { "NOTION_ALLOWED_OPERATIONS": "read,append_blocks,add_page_comment" } }
279
+ ```
280
+
281
+ **Rules:** tokens are case-insensitive; unknown tokens are ignored with a warning; the
282
+ blocklist wins on conflict; and if the allowlist is set but resolves to no enabled
283
+ operations (all tokens invalid, or every allowed op also blocked), **all** operations are
284
+ disabled (fail-closed). Disabled operations are hidden from the `notion://operations`
285
+ menu and from `notion_describe`, and `notion_execute` rejects them with
286
+ `operation_not_allowed`.
287
+
288
+ **Verifying your configuration.** On startup the server prints one line to **stderr**
289
+ (visible in your MCP client's server logs) summarizing what resolved, e.g.:
290
+
291
+ ```text
292
+ Operation access: 16/37 enabled (allow=read; block=(none))
293
+ ```
294
+
295
+ Unknown tokens and a fail-closed allowlist are logged there too. If the count or the
296
+ `allow`/`block` values aren't what you expect, check that line first.
297
+
298
+ **Limitations** (control is per-operation, not per-parameter):
299
+
300
+ - The `destructive` group covers operations whose *purpose* is removal (`trash_page`,
301
+ `archive_page`, `delete_block`, `delete_comment`, `batch_mixed_blocks`). A few *write*
302
+ operations can also remove content via a parameter — e.g. `update_database` /
303
+ `update_data_source` accept `in_trash`, and `update_page_markdown` can replace a page
304
+ body. Blocking `destructive` does **not** disable those write ops. **For a guaranteed
305
+ no-mutation deployment, use the allowlist** (`NOTION_ALLOWED_OPERATIONS=read`), which
306
+ excludes every write operation.
307
+ - MCP *prompts* (e.g. the daily-log prompt) may still reference operations you have
308
+ disabled. The prompt text is unaffected by the allowlist; the underlying operation is
309
+ still rejected at execution time.
310
+
222
311
  ### Claude Code / Cursor / Claude Desktop
223
312
 
224
313
  **Claude Code:**
@@ -1,11 +1,12 @@
1
1
  import { z } from "zod";
2
- import { getOperation, operationNames } from "../operations/registry.js";
2
+ import { getOperation } from "../operations/registry.js";
3
3
  import { buildKey, lookup, store } from "./idempotency.js";
4
4
  import { mapWithConcurrency } from "./concurrency.js";
5
5
  import { rateLimiter } from "./rate-limit.js";
6
6
  import { isRetryableErrorCode, withRetry } from "./retry.js";
7
7
  import { buildValidationError } from "../utils/learning-error.js";
8
8
  import { toErrorEnvelope } from "../utils/error.js";
9
+ import { isOperationAllowed, operationNotAllowedError, enabledOperationNames, } from "../operations/access.js";
9
10
  const DEFAULT_CONCURRENCY = 3;
10
11
  const MAX_CONCURRENCY = 10;
11
12
  function isBatchPayload(payload) {
@@ -16,8 +17,8 @@ function isBatchPayload(payload) {
16
17
  function unknownOperationError(name) {
17
18
  return {
18
19
  code: "unknown_operation",
19
- message: `Unknown operation: "${name}". Use notion_describe with a valid operation name, or check the notion://operations resource for the full list.`,
20
- fix: `Available operations: ${operationNames().join(", ")}`,
20
+ message: `Unknown operation: "${name}". Use notion_describe with a valid operation name, or check the notion://operations resource for the available list.`,
21
+ fix: `Available operations: ${enabledOperationNames().join(", ")}`,
21
22
  };
22
23
  }
23
24
  export async function dispatch(operationName, payload) {
@@ -25,6 +26,9 @@ export async function dispatch(operationName, payload) {
25
26
  if (!def) {
26
27
  return { ok: false, error: unknownOperationError(operationName) };
27
28
  }
29
+ if (!isOperationAllowed(operationName)) {
30
+ return { ok: false, error: operationNotAllowedError(operationName) };
31
+ }
28
32
  if (isBatchPayload(payload)) {
29
33
  if (!def.batchable) {
30
34
  // batch_mixed_blocks looks batch-shaped but uses its own `operations[]`
@@ -0,0 +1,125 @@
1
+ import { listOperations } from "./registry.js";
2
+ export const ALLOWED_ENV_VAR = "NOTION_ALLOWED_OPERATIONS";
3
+ export const BLOCKED_ENV_VAR = "NOTION_BLOCKED_OPERATIONS";
4
+ function parseList(raw) {
5
+ if (!raw)
6
+ return [];
7
+ return raw
8
+ .split(",")
9
+ .map((s) => s.trim().toLowerCase())
10
+ .filter(Boolean);
11
+ }
12
+ /** Every domain is a valid group token. Typed so a new OperationDomain is a compile error here until added. */
13
+ const DOMAIN_GROUPS = [
14
+ "pages",
15
+ "blocks",
16
+ "databases",
17
+ "data_sources",
18
+ "comments",
19
+ "users",
20
+ "files",
21
+ ];
22
+ /** Expand a single token to op names, or null if it matches no group and no op. */
23
+ function expandToken(token, ops) {
24
+ switch (token) {
25
+ case "read":
26
+ return ops.filter((o) => o.access === "read").map((o) => o.name);
27
+ case "write":
28
+ return ops.filter((o) => o.access === "write").map((o) => o.name);
29
+ case "destructive":
30
+ return ops.filter((o) => o.destructive === true).map((o) => o.name);
31
+ default:
32
+ if (DOMAIN_GROUPS.includes(token)) {
33
+ return ops.filter((o) => o.domain === token).map((o) => o.name);
34
+ }
35
+ return ops.some((o) => o.name === token) ? [token] : null;
36
+ }
37
+ }
38
+ /** Pure resolver: (ops, allow, block) -> enabled set. No env / registry access. */
39
+ export function resolveEnabled(ops, allowEnv, blockEnv) {
40
+ const warnings = [];
41
+ const expand = (tokens, label) => {
42
+ const set = new Set();
43
+ for (const token of tokens) {
44
+ const names = expandToken(token, ops);
45
+ if (names === null) {
46
+ warnings.push(`Unknown ${label} token: "${token}" (ignored)`);
47
+ continue;
48
+ }
49
+ names.forEach((n) => set.add(n));
50
+ }
51
+ return set;
52
+ };
53
+ const allowSpecified = allowEnv !== undefined && allowEnv.trim() !== "";
54
+ const allowSet = allowSpecified
55
+ ? expand(parseList(allowEnv), ALLOWED_ENV_VAR)
56
+ : new Set(ops.map((o) => o.name));
57
+ const blockSet = expand(parseList(blockEnv), BLOCKED_ENV_VAR);
58
+ const enabled = new Set([...allowSet].filter((n) => !blockSet.has(n)));
59
+ // An allowlist that resolves to nothing executable — every token invalid, or
60
+ // every allowed op also blocked — is a misconfiguration. Surface it loudly
61
+ // rather than silently running the server with zero operations enabled.
62
+ const failedClosed = allowSpecified && enabled.size === 0;
63
+ return { enabled, warnings, failedClosed };
64
+ }
65
+ // ── Memoized singleton over the real registry + process.env ────────────────
66
+ let cache = null;
67
+ function compute() {
68
+ const ops = listOperations().map((o) => ({
69
+ name: o.name,
70
+ access: o.access,
71
+ domain: o.domain,
72
+ destructive: o.destructive,
73
+ }));
74
+ const result = resolveEnabled(ops, process.env[ALLOWED_ENV_VAR], process.env[BLOCKED_ENV_VAR]);
75
+ for (const w of result.warnings) {
76
+ console.error(`[operation-access] ${w}`);
77
+ }
78
+ if (result.failedClosed) {
79
+ console.error(`[operation-access] ${ALLOWED_ENV_VAR} resolved to zero enabled operations — ALL operations are disabled. Check for unknown tokens, or an allowlist fully cancelled by ${BLOCKED_ENV_VAR}.`);
80
+ }
81
+ return result;
82
+ }
83
+ function get() {
84
+ if (cache)
85
+ return cache;
86
+ const result = compute();
87
+ // Don't memoize a result derived from an unpopulated registry (e.g. if called
88
+ // before initOperations() ran) — recompute once operations are registered.
89
+ if (listOperations().length > 0)
90
+ cache = result;
91
+ return result;
92
+ }
93
+ export function isOperationAllowed(name) {
94
+ return get().enabled.has(name);
95
+ }
96
+ /** Enabled op names in registry order. */
97
+ export function enabledOperationNames() {
98
+ const { enabled } = get();
99
+ return listOperations()
100
+ .map((o) => o.name)
101
+ .filter((n) => enabled.has(n));
102
+ }
103
+ export function enabledOperations() {
104
+ const { enabled } = get();
105
+ return listOperations().filter((o) => enabled.has(o.name));
106
+ }
107
+ export function accessSummary() {
108
+ return {
109
+ enabled: get().enabled.size,
110
+ total: listOperations().length,
111
+ allow: process.env[ALLOWED_ENV_VAR]?.trim() || "(all)",
112
+ block: process.env[BLOCKED_ENV_VAR]?.trim() || "(none)",
113
+ };
114
+ }
115
+ /** Clear the memoized result so the next call re-reads env. Used by tests. */
116
+ export function configureOperationAccess() {
117
+ cache = null;
118
+ }
119
+ export function operationNotAllowedError(name) {
120
+ return {
121
+ code: "operation_not_allowed",
122
+ message: `Operation "${name}" is disabled by server configuration.`,
123
+ fix: `Enabled operations: ${enabledOperationNames().join(", ")}`,
124
+ };
125
+ }
@@ -27,6 +27,8 @@ const AppendBlocksParams = z
27
27
  });
28
28
  register({
29
29
  name: "append_blocks",
30
+ access: "write",
31
+ domain: "blocks",
30
32
  description: "Append children to a page or block. Use markdown for prose content.",
31
33
  batchable: true,
32
34
  schema: AppendBlocksParams,
@@ -99,6 +101,8 @@ const GetBlockParams = z.object({
99
101
  });
100
102
  register({
101
103
  name: "get_block",
104
+ access: "read",
105
+ domain: "blocks",
102
106
  description: "Retrieve a single block by ID (metadata + type-specific body). For its children, use get_block_children.",
103
107
  batchable: true,
104
108
  schema: GetBlockParams,
@@ -123,6 +127,8 @@ const GetBlockChildrenParams = z.object({
123
127
  });
124
128
  register({
125
129
  name: "get_block_children",
130
+ access: "read",
131
+ domain: "blocks",
126
132
  description: "List child blocks under a page or block, paginated.",
127
133
  batchable: false,
128
134
  schema: GetBlockChildrenParams,
@@ -191,6 +197,8 @@ const UpdateBlockParams = z
191
197
  });
192
198
  register({
193
199
  name: "update_block",
200
+ access: "write",
201
+ domain: "blocks",
194
202
  description: "Update an existing block's content. Pass `markdown` for prose blocks (parsed locally to a single block), or `data` for structured updates such as toggling a to_do's `checked` field or setting a code block's language.",
195
203
  batchable: true,
196
204
  schema: UpdateBlockParams,
@@ -234,6 +242,9 @@ register({
234
242
  const DeleteBlockParams = z.object({ block_id: z.string(), verbose: VERBOSE });
235
243
  register({
236
244
  name: "delete_block",
245
+ access: "write",
246
+ domain: "blocks",
247
+ destructive: true,
237
248
  description: "Archive (soft-delete) a block.",
238
249
  batchable: true,
239
250
  schema: DeleteBlockParams,
@@ -272,6 +283,9 @@ const BatchMixedBlocksParams = z.object({
272
283
  });
273
284
  register({
274
285
  name: "batch_mixed_blocks",
286
+ access: "write",
287
+ domain: "blocks",
288
+ destructive: true,
275
289
  description: "Run a sequence of mixed block operations (append/update/delete) in order. Uses a non-standard envelope: { operations: [{ op: \"append\"|\"update\"|\"delete\", ... }] } — NOT the universal { items: [...] } batch envelope. For pure single-op batches, prefer the items[] form on append_blocks / update_block / delete_block.",
276
290
  batchable: false,
277
291
  schema: BatchMixedBlocksParams,
@@ -30,6 +30,8 @@ const ListCommentsParams = z.object({
30
30
  });
31
31
  register({
32
32
  name: "list_comments",
33
+ access: "read",
34
+ domain: "comments",
33
35
  description: "List comments on a page or block. Pass paginate:true to auto-walk all pages.",
34
36
  batchable: false,
35
37
  schema: ListCommentsParams,
@@ -77,6 +79,8 @@ const AddPageCommentParams = z
77
79
  });
78
80
  register({
79
81
  name: "add_page_comment",
82
+ access: "write",
83
+ domain: "comments",
80
84
  description: "Add a top-level comment to a page. Body can be plain text or markdown.",
81
85
  batchable: true,
82
86
  schema: AddPageCommentParams,
@@ -111,6 +115,8 @@ const AddDiscussionCommentParams = z
111
115
  });
112
116
  register({
113
117
  name: "add_discussion_comment",
118
+ access: "write",
119
+ domain: "comments",
114
120
  description: "Reply to an existing discussion thread. Body can be plain text or markdown.",
115
121
  batchable: true,
116
122
  schema: AddDiscussionCommentParams,
@@ -133,6 +139,8 @@ const GetCommentParams = z.object({
133
139
  });
134
140
  register({
135
141
  name: "get_comment",
142
+ access: "read",
143
+ domain: "comments",
136
144
  description: "Retrieve a single comment by ID.",
137
145
  batchable: true,
138
146
  schema: GetCommentParams,
@@ -158,6 +166,8 @@ const UpdateCommentParams = z
158
166
  });
159
167
  register({
160
168
  name: "update_comment",
169
+ access: "write",
170
+ domain: "comments",
161
171
  description: "Replace a comment's body. Pass markdown or rich_text (not both).",
162
172
  batchable: true,
163
173
  schema: UpdateCommentParams,
@@ -179,6 +189,9 @@ const DeleteCommentParams = z.object({
179
189
  });
180
190
  register({
181
191
  name: "delete_comment",
192
+ access: "write",
193
+ domain: "comments",
194
+ destructive: true,
182
195
  description: "Delete a comment.",
183
196
  batchable: true,
184
197
  schema: DeleteCommentParams,
@@ -13,6 +13,8 @@ const ListDataSourcesParams = z.object({
13
13
  });
14
14
  register({
15
15
  name: "list_data_sources",
16
+ access: "read",
17
+ domain: "data_sources",
16
18
  description: "List data sources under a database. Use this before query_database when targeting multi-source databases.",
17
19
  batchable: false,
18
20
  schema: ListDataSourcesParams,
@@ -38,6 +40,8 @@ const GetDataSourceParams = z.object({
38
40
  });
39
41
  register({
40
42
  name: "get_data_source",
43
+ access: "read",
44
+ domain: "data_sources",
41
45
  description: "Retrieve a single data source's schema (its property definitions and parent database).",
42
46
  batchable: true,
43
47
  schema: GetDataSourceParams,
@@ -60,6 +64,8 @@ const UpdateDataSourceParams = z.object({
60
64
  });
61
65
  register({
62
66
  name: "update_data_source",
67
+ access: "write",
68
+ domain: "data_sources",
63
69
  description: "Update a data source's schema (properties, title, icon). For database-level metadata use update_database.",
64
70
  batchable: true,
65
71
  schema: UpdateDataSourceParams,
@@ -45,6 +45,8 @@ function resolveParent(parent) {
45
45
  }
46
46
  register({
47
47
  name: "create_database",
48
+ access: "write",
49
+ domain: "databases",
48
50
  description: "Create a new database. Properties is a map of name → property-type definition.",
49
51
  batchable: true,
50
52
  schema: CreateDatabaseParams,
@@ -138,6 +140,8 @@ const QueryDatabaseParams = z
138
140
  });
139
141
  register({
140
142
  name: "query_database",
143
+ access: "read",
144
+ domain: "databases",
141
145
  description: "Query a database with optional filter and sorts. Results are page objects.",
142
146
  batchable: false,
143
147
  schema: QueryDatabaseParams,
@@ -304,6 +308,8 @@ const UpdateDatabaseParams = z.object({
304
308
  });
305
309
  register({
306
310
  name: "update_database",
311
+ access: "write",
312
+ domain: "databases",
307
313
  description: "Update database-level metadata (title, description, icon, cover, is_inline, is_locked, in_trash). For schema/property changes, use update_data_source.",
308
314
  batchable: true,
309
315
  schema: UpdateDatabaseParams,
@@ -121,6 +121,8 @@ const UploadFileParams = z.object({
121
121
  });
122
122
  register({
123
123
  name: "upload_file",
124
+ access: "write",
125
+ domain: "files",
124
126
  description: "Upload a file via Notion's file_uploads API. Handles single-part (one create + one send) and multi-part (create + N sends + complete) transparently.\n\nSource shapes:\n • Base64 bytes: `source: { type: \"base64\", data: \"<b64 string>\" }`\n • Public URL: `source: { type: \"url\", url: \"https://example.com/file.pdf\" }` (the server fetches it server-side).\n\n`mode` defaults to \"single\"; only pass \"multi\" for files larger than ~5MB.",
125
127
  batchable: false,
126
128
  schema: UploadFileParams,
@@ -205,6 +207,8 @@ const ListFileUploadsParams = z.object({
205
207
  });
206
208
  register({
207
209
  name: "list_file_uploads",
210
+ access: "read",
211
+ domain: "files",
208
212
  description: "List file uploads, optionally filtered by status.",
209
213
  batchable: false,
210
214
  schema: ListFileUploadsParams,
@@ -231,6 +235,8 @@ const GetFileUploadParams = z.object({
231
235
  });
232
236
  register({
233
237
  name: "get_file_upload",
238
+ access: "read",
239
+ domain: "files",
234
240
  description: "Retrieve a single file upload by ID.",
235
241
  batchable: true,
236
242
  schema: GetFileUploadParams,
@@ -56,6 +56,8 @@ const CreatePageParams = z
56
56
  });
57
57
  register({
58
58
  name: "create_page",
59
+ access: "write",
60
+ domain: "pages",
59
61
  description: "Create a new Notion page. Body can be markdown (recommended) or structured blocks.",
60
62
  batchable: true,
61
63
  schema: CreatePageParams,
@@ -123,6 +125,8 @@ const SetPageTitleParams = z.object({
123
125
  });
124
126
  register({
125
127
  name: "set_page_title",
128
+ access: "write",
129
+ domain: "pages",
126
130
  description: "Rename a page. Updates the page's title property.",
127
131
  batchable: true,
128
132
  schema: SetPageTitleParams,
@@ -170,6 +174,8 @@ const SetPagePropertyParams = z.preprocess(wrapTitleShorthand, z.object({
170
174
  }));
171
175
  register({
172
176
  name: "set_page_property",
177
+ access: "write",
178
+ domain: "pages",
173
179
  description: "Set one property on one page. For batch updates use items[].",
174
180
  batchable: true,
175
181
  schema: SetPagePropertyParams,
@@ -220,6 +226,8 @@ const SetPagePropertiesParams = z.preprocess(wrapTitleShorthandInProperties, z.o
220
226
  }));
221
227
  register({
222
228
  name: "set_page_properties",
229
+ access: "write",
230
+ domain: "pages",
223
231
  description: "Set multiple properties on one page in a single API call. Use set_page_property for one-off updates.",
224
232
  batchable: true,
225
233
  schema: SetPagePropertiesParams,
@@ -260,6 +268,9 @@ const archivePageHandler = tryHandler(async ({ page_id, verbose }) => {
260
268
  });
261
269
  register({
262
270
  name: "archive_page",
271
+ access: "write",
272
+ domain: "pages",
273
+ destructive: true,
263
274
  description: "Move a page to trash. Reversible via restore_page. Alias: trash_page.",
264
275
  batchable: true,
265
276
  schema: PageIdParams,
@@ -269,6 +280,9 @@ register({
269
280
  });
270
281
  register({
271
282
  name: "trash_page",
283
+ access: "write",
284
+ domain: "pages",
285
+ destructive: true,
272
286
  description: "Alias of archive_page (2025-09-03 surface naming). Moves a page to trash.",
273
287
  batchable: true,
274
288
  schema: PageIdParams,
@@ -278,6 +292,8 @@ register({
278
292
  });
279
293
  register({
280
294
  name: "restore_page",
295
+ access: "write",
296
+ domain: "pages",
281
297
  description: "Restore a page previously moved to trash.",
282
298
  batchable: true,
283
299
  schema: PageIdParams,
@@ -310,6 +326,8 @@ const SearchPagesParams = z.object({
310
326
  });
311
327
  register({
312
328
  name: "search_pages",
329
+ access: "read",
330
+ domain: "pages",
313
331
  description: "Search pages and databases by title. Title-only; does NOT search page body content. Pass paginate:true to auto-walk all pages.",
314
332
  batchable: false,
315
333
  schema: SearchPagesParams,
@@ -360,6 +378,8 @@ const GetPageParams = z.object({
360
378
  });
361
379
  register({
362
380
  name: "get_page",
381
+ access: "read",
382
+ domain: "pages",
363
383
  description: "Retrieve a page's metadata and (optionally) properties. No body blocks — use get_block_children for those. Pass `include_properties: true` to also return a flattened properties map.",
364
384
  batchable: true,
365
385
  schema: GetPageParams,
@@ -383,6 +403,8 @@ const MovePageParams = z.object({
383
403
  });
384
404
  register({
385
405
  name: "move_page",
406
+ access: "write",
407
+ domain: "pages",
386
408
  description: "Move a page to a new parent without recreating it. Preserves the page's blocks, properties, and comments. The destination uses `parent` — same field name as create_page.",
387
409
  batchable: true,
388
410
  schema: MovePageParams,
@@ -422,6 +444,8 @@ const GetPageMarkdownParams = z.object({
422
444
  });
423
445
  register({
424
446
  name: "get_page_markdown",
447
+ access: "read",
448
+ domain: "pages",
425
449
  description: "Return a page's body as Notion-rendered markdown. Server-side conversion; round-trips with update_page_markdown.",
426
450
  batchable: true,
427
451
  schema: GetPageMarkdownParams,
@@ -451,6 +475,8 @@ const UpdatePageMarkdownParams = z.object({
451
475
  });
452
476
  register({
453
477
  name: "update_page_markdown",
478
+ access: "write",
479
+ domain: "pages",
454
480
  description: "Replace (or insert into) a page's body using Notion's server-side markdown renderer. Skip the local remark pipeline.",
455
481
  batchable: true,
456
482
  schema: UpdatePageMarkdownParams,
@@ -25,6 +25,8 @@ const ListUsersParams = z.object({
25
25
  });
26
26
  register({
27
27
  name: "list_users",
28
+ access: "read",
29
+ domain: "users",
28
30
  description: "List all users in the workspace. Requires the integration to have 'Read user information' capability enabled. Pass paginate:true to auto-walk all pages.",
29
31
  batchable: false,
30
32
  schema: ListUsersParams,
@@ -64,6 +66,8 @@ register({
64
66
  const GetUserParams = z.object({ user_id: z.string(), verbose: VERBOSE });
65
67
  register({
66
68
  name: "get_user",
69
+ access: "read",
70
+ domain: "users",
67
71
  description: "Get one user by ID. Requires 'Read user information' capability.",
68
72
  batchable: true,
69
73
  schema: GetUserParams,
@@ -85,6 +89,8 @@ const getBotUserHandler = tryHandler(async ({ verbose }) => {
85
89
  });
86
90
  register({
87
91
  name: "get_bot_user",
92
+ access: "read",
93
+ domain: "users",
88
94
  description: "Get the integration's bot user. Always works without extra capabilities. Alias: get_self.",
89
95
  batchable: false,
90
96
  schema: GetBotUserParams,
@@ -93,6 +99,8 @@ register({
93
99
  });
94
100
  register({
95
101
  name: "get_self",
102
+ access: "read",
103
+ domain: "users",
96
104
  description: "Alias of get_bot_user. Returns the integration's bot user (i.e. the identity behind the current token).",
97
105
  batchable: false,
98
106
  schema: GetBotUserParams,
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { server } from "../server/index.js";
3
- import { initOperations, getOperation, listOperations, operationNames } from "../operations/index.js";
3
+ import { initOperations, getOperation } from "../operations/index.js";
4
+ import { isOperationAllowed, operationNotAllowedError, enabledOperationNames, enabledOperations, accessSummary, } from "../operations/access.js";
4
5
  import { dispatch } from "../dispatch/index.js";
5
6
  import { emitJsonSchema } from "../schema/emit.js";
6
7
  import { registerAllPrompts } from "../prompts/index.js";
@@ -74,10 +75,13 @@ export async function registerAllTools() {
74
75
  error: {
75
76
  code: "unknown_operation",
76
77
  message: `Unknown operation: "${operation}".`,
77
- fix: `Available: ${operationNames().join(", ")}`,
78
+ fix: `Available: ${enabledOperationNames().join(", ")}`,
78
79
  },
79
80
  });
80
81
  }
82
+ if (!isOperationAllowed(operation)) {
83
+ return errorContent({ ok: false, error: operationNotAllowedError(operation) });
84
+ }
81
85
  return jsonContent({
82
86
  name: def.name,
83
87
  description: def.description,
@@ -102,6 +106,8 @@ export async function registerAllTools() {
102
106
  ],
103
107
  }));
104
108
  registerAllPrompts();
109
+ const s = accessSummary();
110
+ console.error(`Operation access: ${s.enabled}/${s.total} enabled (allow=${s.allow}; block=${s.block})`);
105
111
  }
106
112
  function renderOperationsIndex() {
107
113
  const lines = [
@@ -112,9 +118,14 @@ function renderOperationsIndex() {
112
118
  "| Operation | Batchable | Description |",
113
119
  "| --- | --- | --- |",
114
120
  ];
115
- for (const def of listOperations()) {
121
+ for (const def of enabledOperations()) {
116
122
  lines.push(`| \`${def.name}\` | ${def.batchable ? "yes" : "no"} | ${def.description} |`);
117
123
  }
124
+ // Only document the query_database filter DSL when that op is actually enabled —
125
+ // otherwise the menu advertises a disabled operation.
126
+ if (!isOperationAllowed("query_database")) {
127
+ return lines.join("\n");
128
+ }
118
129
  lines.push("", "## `query_database` WHERE DSL", "");
119
130
  lines.push("`query_database.where` is a compact DSL that compiles to the Notion filter object. AND-by-default at the top level; nest `and`/`or`/`not` (case-insensitive — `AND`/`OR`/`NOT` also work) for boolean groups, prefix scalars with `__type` to force the property type, or fall back to raw `filter` for anything the DSL can't express.", "", "Common shapes:", "", "```jsonc", "// Single equality (property type inferred from value, or from data source schema via __type):", "{ \"where\": { \"Status\": \"Open\" } }", "", "// AND of multiple properties (top-level keys are implicit AND):", "{ \"where\": { \"Status\": \"Done\", \"Done\": true } }", "", "// Explicit operator on one property:", "{ \"where\": { \"Priority\": { \"gte\": 3 } } }", "", "// Boolean groups (lowercase or uppercase — both work):", "{ \"where\": { \"or\": [ { \"Status\": \"Open\" }, { \"Status\": \"In progress\" } ] } }", "{ \"where\": { \"and\": [ { \"Status\": \"Done\" }, { \"Priority\": { \"gte\": 5 } } ] } }", "{ \"where\": { \"not\": { \"Status\": \"Done\" } } }", "", "// in / notIn fan out to OR / AND of equals:", "{ \"where\": { \"Status\": { \"in\": [\"Open\", \"In progress\"] } } }", "", "// Force property type when value shape is ambiguous (e.g. a string that's actually a multi_select tag):", "{ \"where\": { \"Tags\": { \"__type\": \"multi_select\", \"eq\": \"alpha\" } } }", "{ \"where\": { \"Created\": { \"__type\": \"date\", \"on_or_after\": \"2026-01-01\" } } }", "```", "", "If a column is literally named `and`/`or`/`not`, wrap it as an operator object (e.g. `{ \"and\": { \"__type\": \"select\", \"eq\": \"x\" } }`) so it isn't parsed as a combinator. For anything the DSL can't express, pass `filter` (raw Notion filter object) instead of `where`.");
120
131
  return lines.join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notion-mcp-server",
3
- "version": "2.4.4",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "notion-mcp-server": "build/index.js"
@@ -49,6 +49,7 @@
49
49
  "zod": "^4.4.3"
50
50
  },
51
51
  "devDependencies": {
52
+ "@types/mdast": "^4.0.4",
52
53
  "@types/node": "^22.13.10",
53
54
  "shx": "^0.3.4",
54
55
  "typescript": "^5.8.2",