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 +89 -0
- package/build/dispatch/index.js +7 -3
- package/build/operations/access.js +125 -0
- package/build/operations/blocks.js +14 -0
- package/build/operations/comments.js +13 -0
- package/build/operations/data-sources.js +6 -0
- package/build/operations/databases.js +6 -0
- package/build/operations/files.js +6 -0
- package/build/operations/pages.js +26 -0
- package/build/operations/users.js +8 -0
- package/build/tools/index.js +14 -3
- package/package.json +2 -1
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:**
|
package/build/dispatch/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { getOperation
|
|
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
|
|
20
|
-
fix: `Available operations: ${
|
|
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,
|
package/build/tools/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { server } from "../server/index.js";
|
|
3
|
-
import { initOperations, getOperation
|
|
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: ${
|
|
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
|
|
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.
|
|
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",
|