notion-mcp-server 2.5.1 → 2.6.1
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 +15 -2
- package/build/config/index.js +8 -1
- package/build/operations/access.js +18 -3
- package/build/tools/index.js +22 -1
- package/build/tools/resources.js +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -211,6 +211,7 @@ If you ran a v1.x setup, **nothing in your environment needs to change**. Both e
|
|
|
211
211
|
| `NOTION_RATE_LIMIT` | ✅ New, optional | Requests per second for the shared limiter. Defaults to `3` (Notion's documented per-integration limit). |
|
|
212
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
213
|
| `NOTION_BLOCKED_OPERATIONS` | ✅ New, optional | Comma-separated blocklist (same token vocabulary). Applied after the allowlist, so a blocked operation is always disabled. |
|
|
214
|
+
| `NOTION_READ_ONLY` | ✅ New, optional | Set to `true`/`1`/`yes` to disable every write operation in one switch (equivalent to `NOTION_BLOCKED_OPERATIONS=write`). Composes with the allow/block lists. See [Restricting operations](#restricting-operations). |
|
|
214
215
|
| `NOTION_DAILY_LOG_PAGE_ID` | ✅ Optional | Used only by the daily-log MCP prompt. Ignore if you don't call that prompt. |
|
|
215
216
|
|
|
216
217
|
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.
|
|
@@ -302,8 +303,8 @@ Unknown tokens and a fail-closed allowlist are logged there too. If the count or
|
|
|
302
303
|
operations can also remove content via a parameter — e.g. `update_database` /
|
|
303
304
|
`update_data_source` accept `in_trash`, and `update_page_markdown` can replace a page
|
|
304
305
|
body. Blocking `destructive` does **not** disable those write ops. **For a guaranteed
|
|
305
|
-
no-mutation deployment, use the allowlist** (`NOTION_ALLOWED_OPERATIONS=read`)
|
|
306
|
-
|
|
306
|
+
no-mutation deployment, use the allowlist** (`NOTION_ALLOWED_OPERATIONS=read`) or the
|
|
307
|
+
shorthand `NOTION_READ_ONLY=true` — both leave only read operations enabled.
|
|
307
308
|
- MCP *prompts* (e.g. the daily-log prompt) may still reference operations you have
|
|
308
309
|
disabled. The prompt text is unaffected by the allowlist; the underlying operation is
|
|
309
310
|
still rejected at execution time.
|
|
@@ -475,6 +476,18 @@ Return the JSON Schema + working example for a single operation. Use this when y
|
|
|
475
476
|
|
|
476
477
|
The authoritative list (with batchability) is also served as an MCP resource at `notion://operations` — useful as a one-shot cheat sheet for the LLM.
|
|
477
478
|
|
|
479
|
+
### MCP resources
|
|
480
|
+
|
|
481
|
+
The server exposes three resources, so clients that support resource attachment (`@`-mention) can pull Notion content into context without a tool call:
|
|
482
|
+
|
|
483
|
+
| Resource URI | Returns |
|
|
484
|
+
| --- | --- |
|
|
485
|
+
| `notion://operations` | Markdown cheat sheet of every enabled operation. |
|
|
486
|
+
| `notion://page/<page_id>` | The page body as markdown. |
|
|
487
|
+
| `notion://database/<data_source_id>` | The data source's schema as JSON. |
|
|
488
|
+
|
|
489
|
+
The dynamic page/database resources route through the same auth, rate limiting, and access gating as tool calls — a page disabled by your allow/block config (or `NOTION_READ_ONLY`) returns an error envelope, not content.
|
|
490
|
+
|
|
478
491
|
---
|
|
479
492
|
|
|
480
493
|
## 🛠 Development
|
package/build/config/index.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
// Read the version straight from package.json so the MCP handshake always
|
|
3
|
+
// reports the real published version instead of a hand-maintained constant
|
|
4
|
+
// that silently drifts. createRequire resolves relative to this module, which
|
|
5
|
+
// is two levels deep in both src/ (src/config/index.ts) and build/.
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
const pkg = require("../../package.json");
|
|
1
8
|
// Configuration
|
|
2
9
|
export const CONFIG = {
|
|
3
10
|
serverName: "notion-mcp-server",
|
|
4
11
|
serverTitle: "Notion",
|
|
5
|
-
serverVersion:
|
|
12
|
+
serverVersion: pkg.version,
|
|
6
13
|
serverUrl: "https://github.com/awkoy/notion-mcp-server",
|
|
7
14
|
};
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { listOperations } from "./registry.js";
|
|
2
2
|
export const ALLOWED_ENV_VAR = "NOTION_ALLOWED_OPERATIONS";
|
|
3
3
|
export const BLOCKED_ENV_VAR = "NOTION_BLOCKED_OPERATIONS";
|
|
4
|
+
export const READ_ONLY_ENV_VAR = "NOTION_READ_ONLY";
|
|
5
|
+
/** Interpret common truthy strings ("true", "1", "yes", "on") as enabling read-only mode. */
|
|
6
|
+
export function parseReadOnly(raw) {
|
|
7
|
+
if (!raw)
|
|
8
|
+
return false;
|
|
9
|
+
return ["true", "1", "yes", "on"].includes(raw.trim().toLowerCase());
|
|
10
|
+
}
|
|
4
11
|
function parseList(raw) {
|
|
5
12
|
if (!raw)
|
|
6
13
|
return [];
|
|
@@ -35,8 +42,8 @@ function expandToken(token, ops) {
|
|
|
35
42
|
return ops.some((o) => o.name === token) ? [token] : null;
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
|
-
/** Pure resolver: (ops, allow, block) -> enabled set. No env / registry access. */
|
|
39
|
-
export function resolveEnabled(ops, allowEnv, blockEnv) {
|
|
45
|
+
/** Pure resolver: (ops, allow, block, readOnly) -> enabled set. No env / registry access. */
|
|
46
|
+
export function resolveEnabled(ops, allowEnv, blockEnv, readOnly = false) {
|
|
40
47
|
const warnings = [];
|
|
41
48
|
const expand = (tokens, label) => {
|
|
42
49
|
const set = new Set();
|
|
@@ -55,6 +62,13 @@ export function resolveEnabled(ops, allowEnv, blockEnv) {
|
|
|
55
62
|
? expand(parseList(allowEnv), ALLOWED_ENV_VAR)
|
|
56
63
|
: new Set(ops.map((o) => o.name));
|
|
57
64
|
const blockSet = expand(parseList(blockEnv), BLOCKED_ENV_VAR);
|
|
65
|
+
// Read-only mode is sugar for "block every write op" — it layers onto the
|
|
66
|
+
// blocklist so it composes with any allow/block configuration already set.
|
|
67
|
+
if (readOnly) {
|
|
68
|
+
for (const o of ops)
|
|
69
|
+
if (o.access === "write")
|
|
70
|
+
blockSet.add(o.name);
|
|
71
|
+
}
|
|
58
72
|
const enabled = new Set([...allowSet].filter((n) => !blockSet.has(n)));
|
|
59
73
|
// An allowlist that resolves to nothing executable — every token invalid, or
|
|
60
74
|
// every allowed op also blocked — is a misconfiguration. Surface it loudly
|
|
@@ -71,7 +85,7 @@ function compute() {
|
|
|
71
85
|
domain: o.domain,
|
|
72
86
|
destructive: o.destructive,
|
|
73
87
|
}));
|
|
74
|
-
const result = resolveEnabled(ops, process.env[ALLOWED_ENV_VAR], process.env[BLOCKED_ENV_VAR]);
|
|
88
|
+
const result = resolveEnabled(ops, process.env[ALLOWED_ENV_VAR], process.env[BLOCKED_ENV_VAR], parseReadOnly(process.env[READ_ONLY_ENV_VAR]));
|
|
75
89
|
for (const w of result.warnings) {
|
|
76
90
|
console.error(`[operation-access] ${w}`);
|
|
77
91
|
}
|
|
@@ -110,6 +124,7 @@ export function accessSummary() {
|
|
|
110
124
|
total: listOperations().length,
|
|
111
125
|
allow: process.env[ALLOWED_ENV_VAR]?.trim() || "(all)",
|
|
112
126
|
block: process.env[BLOCKED_ENV_VAR]?.trim() || "(none)",
|
|
127
|
+
readOnly: parseReadOnly(process.env[READ_ONLY_ENV_VAR]),
|
|
113
128
|
};
|
|
114
129
|
}
|
|
115
130
|
/** Clear the memoized result so the next call re-reads env. Used by tests. */
|
package/build/tools/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import { server } from "../server/index.js";
|
|
4
|
+
import { readNotionResource } from "./resources.js";
|
|
3
5
|
import { initOperations, getOperation } from "../operations/index.js";
|
|
4
6
|
import { isOperationAllowed, operationNotAllowedError, enabledOperationNames, enabledOperations, accessSummary, } from "../operations/access.js";
|
|
5
7
|
import { dispatch } from "../dispatch/index.js";
|
|
@@ -105,9 +107,28 @@ export async function registerAllTools() {
|
|
|
105
107
|
},
|
|
106
108
|
],
|
|
107
109
|
}));
|
|
110
|
+
// Dynamic resources: let clients @-mention / attach a Notion page or database
|
|
111
|
+
// by id. Pages come back as markdown; databases as their (slim) schema JSON.
|
|
112
|
+
const firstVar = (v) => (Array.isArray(v) ? v[0] : v);
|
|
113
|
+
server.registerResource("notion-page", new ResourceTemplate("notion://page/{pageId}", { list: undefined }), {
|
|
114
|
+
title: "Notion page (markdown)",
|
|
115
|
+
description: "Read any Notion page as markdown by id — notion://page/<page_id>.",
|
|
116
|
+
mimeType: "text/markdown",
|
|
117
|
+
}, async (uri, variables) => {
|
|
118
|
+
const { mimeType, text } = await readNotionResource("page", firstVar(variables.pageId));
|
|
119
|
+
return { contents: [{ uri: uri.href, mimeType, text }] };
|
|
120
|
+
});
|
|
121
|
+
server.registerResource("notion-database", new ResourceTemplate("notion://database/{dataSourceId}", { list: undefined }), {
|
|
122
|
+
title: "Notion database (schema)",
|
|
123
|
+
description: "Read a Notion data source's schema by id — notion://database/<data_source_id>.",
|
|
124
|
+
mimeType: "application/json",
|
|
125
|
+
}, async (uri, variables) => {
|
|
126
|
+
const { mimeType, text } = await readNotionResource("database", firstVar(variables.dataSourceId));
|
|
127
|
+
return { contents: [{ uri: uri.href, mimeType, text }] };
|
|
128
|
+
});
|
|
108
129
|
registerAllPrompts();
|
|
109
130
|
const s = accessSummary();
|
|
110
|
-
console.error(`Operation access: ${s.enabled}/${s.total} enabled (allow=${s.allow}; block=${s.block})`);
|
|
131
|
+
console.error(`Operation access: ${s.enabled}/${s.total} enabled (allow=${s.allow}; block=${s.block}${s.readOnly ? "; read-only" : ""})`);
|
|
111
132
|
}
|
|
112
133
|
function renderOperationsIndex() {
|
|
113
134
|
const lines = [
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { dispatch } from "../dispatch/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Read a Notion entity for exposure as an MCP resource. Routes through the
|
|
4
|
+
* normal dispatch path so resource reads share the same auth, rate limiting,
|
|
5
|
+
* retry, and access gating as tool calls. A failed read returns the error
|
|
6
|
+
* envelope as JSON rather than throwing, so the client still gets a body.
|
|
7
|
+
*/
|
|
8
|
+
export async function readNotionResource(kind, id) {
|
|
9
|
+
if (kind === "page") {
|
|
10
|
+
const result = await dispatch("get_page_markdown", { page_id: id });
|
|
11
|
+
if (result.ok && "data" in result) {
|
|
12
|
+
const data = result.data;
|
|
13
|
+
return { mimeType: "text/markdown", text: data.markdown ?? "" };
|
|
14
|
+
}
|
|
15
|
+
return errorContent(result);
|
|
16
|
+
}
|
|
17
|
+
const result = await dispatch("get_data_source", { data_source_id: id });
|
|
18
|
+
if (result.ok && "data" in result) {
|
|
19
|
+
return { mimeType: "application/json", text: JSON.stringify(result.data) };
|
|
20
|
+
}
|
|
21
|
+
return errorContent(result);
|
|
22
|
+
}
|
|
23
|
+
function errorContent(result) {
|
|
24
|
+
const error = result && typeof result === "object" && "error" in result
|
|
25
|
+
? result.error
|
|
26
|
+
: result;
|
|
27
|
+
return { mimeType: "application/json", text: JSON.stringify(error) };
|
|
28
|
+
}
|