notion-mcp-server 2.7.0 → 2.9.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 +4 -1
- package/build/operations/access.js +1 -0
- package/build/operations/data-sources.js +5 -3
- package/build/operations/index.js +1 -0
- package/build/operations/views.js +456 -0
- package/build/schema/blocks.js +1 -0
- package/build/services/notion.js +18 -1
- package/build/tools/index.js +6 -5
- package/build/utils/slim.js +15 -0
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -247,6 +247,7 @@ blocklist). Each is a comma-separated list of **tokens**, where a token is eithe
|
|
|
247
247
|
| `blocks` | `get_block` `get_block_children` | `append_blocks` `update_block` `delete_block`† `batch_mixed_blocks`† |
|
|
248
248
|
| `databases` | `query_database` | `create_database` `update_database` |
|
|
249
249
|
| `data_sources` | `list_data_sources` `get_data_source` | `update_data_source` |
|
|
250
|
+
| `views` | `list_views` `get_view` `query_view` | `create_view` `update_view` `delete_view`† |
|
|
250
251
|
| `comments` | `list_comments` `get_comment` | `add_page_comment` `add_discussion_comment` `update_comment` `delete_comment`† |
|
|
251
252
|
| `users` | `list_users` `get_user` `get_bot_user` `get_self` | — |
|
|
252
253
|
| `files` | `list_file_uploads` `get_file_upload` | `upload_file` |
|
|
@@ -445,6 +446,7 @@ docker run --rm -e NOTION_TOKEN=ntn_xxx -e MCP_TRANSPORT=http -p 3000:3000 ghcr.
|
|
|
445
446
|
- **File uploads** — `upload_file` handles single-part and multi-part (5 MB chunks) transparently; auto-detects MIME from filename; rejects `application/octet-stream`.
|
|
446
447
|
- **Opt-in auto-pagination** — pass `paginate: true` on `search_pages`, `list_comments`, or `query_database` and the server walks `next_cursor` for you (capped by `page_limit`, default 10 pages ≈ 1000 items at `page_size: 100`). Other list ops return a single Notion page with `has_more` / `next_cursor`.
|
|
447
448
|
- **Typed `where` filter shorthand** — `query_database` accepts a `where` clause like `{Status: {equals: "Done"}, AND: [...]}` with operator objects (`eq`, `ne`, `gte`, `lte`, `contains`, `starts_with`, etc.); the server compiles it to Notion filter JSON. Pass raw Notion `filter` JSON for edge cases the shorthand can't express (the two fields are mutually exclusive).
|
|
449
|
+
- **Database views** — `list_views` / `get_view` / `query_view` plus `create_view` / `update_view` / `delete_view`. `query_view` runs a view's stored filters/sorts and returns hydrated rows by default (`hydrate: false` for ids only); `create_view` / `update_view` reuse the same `where` shorthand for filters and take a raw `configuration` for type-specific layout (calendar/board/timeline/chart/map require it).
|
|
448
450
|
- **Universal MCP compatibility** — Cursor, Claude Desktop, Claude Code, Cline, Zed, Continue, anything that speaks MCP stdio.
|
|
449
451
|
|
|
450
452
|
---
|
|
@@ -506,7 +508,7 @@ Return the JSON Schema + working example for a single operation. Use this when y
|
|
|
506
508
|
{ "operation": "query_database" }
|
|
507
509
|
```
|
|
508
510
|
|
|
509
|
-
### Operations menu (
|
|
511
|
+
### Operations menu (41 ops, plus one alias)
|
|
510
512
|
|
|
511
513
|
| Area | Operations |
|
|
512
514
|
| --- | --- |
|
|
@@ -514,6 +516,7 @@ Return the JSON Schema + working example for a single operation. Use this when y
|
|
|
514
516
|
| **Blocks** | `append_blocks`, `get_block`, `get_block_children`, `update_block`, `delete_block`, `batch_mixed_blocks` |
|
|
515
517
|
| **Databases** | `create_database`, `query_database`, `update_database` |
|
|
516
518
|
| **Data sources** | `list_data_sources`, `get_data_source`, `update_data_source` |
|
|
519
|
+
| **Views** | `list_views`, `get_view`, `query_view`, `create_view`, `update_view`, `delete_view` |
|
|
517
520
|
| **Comments** | `list_comments`, `add_page_comment`, `add_discussion_comment`, `get_comment`, `update_comment`, `delete_comment` |
|
|
518
521
|
| **Users** | `list_users`, `get_user`, `get_bot_user` |
|
|
519
522
|
| **Files** | `upload_file`, `list_file_uploads`, `get_file_upload` |
|
|
@@ -58,7 +58,7 @@ const UpdateDataSourceParams = z.object({
|
|
|
58
58
|
title: z.array(z.unknown()).optional().describe("Rich text array for the data source title."),
|
|
59
59
|
properties: z.record(z.string(), DATABASE_PROPERTY_SCHEMA).optional(),
|
|
60
60
|
icon: z.unknown().optional(),
|
|
61
|
-
archived: z.boolean().optional(),
|
|
61
|
+
archived: z.boolean().optional().describe("Deprecated alias for in_trash (removed on the 2026-03-11 surface). Routed to in_trash."),
|
|
62
62
|
in_trash: z.boolean().optional(),
|
|
63
63
|
verbose: VERBOSE,
|
|
64
64
|
});
|
|
@@ -77,13 +77,15 @@ register({
|
|
|
77
77
|
},
|
|
78
78
|
handler: tryHandler(async ({ data_source_id, title, properties, icon, archived, in_trash, verbose }) => {
|
|
79
79
|
const notion = await getClient();
|
|
80
|
+
// `archived` was removed on the 2026-03-11 surface; route the legacy alias
|
|
81
|
+
// into `in_trash` so we never send a field the API rejects.
|
|
82
|
+
const trash = in_trash ?? archived;
|
|
80
83
|
const body = {
|
|
81
84
|
data_source_id,
|
|
82
85
|
...(title !== undefined ? { title } : {}),
|
|
83
86
|
...(properties !== undefined ? { properties } : {}),
|
|
84
87
|
...(icon !== undefined ? { icon } : {}),
|
|
85
|
-
...(
|
|
86
|
-
...(in_trash !== undefined ? { in_trash } : {}),
|
|
88
|
+
...(trash !== undefined ? { in_trash: trash } : {}),
|
|
87
89
|
};
|
|
88
90
|
const response = await notion.dataSources.update(asSdk(body));
|
|
89
91
|
return { ok: true, data: slimDataSource(response, verbose ?? false) };
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isFullDatabase } from "@notionhq/client";
|
|
3
|
+
import { getClient } from "../services/notion.js";
|
|
4
|
+
import { register } from "./registry.js";
|
|
5
|
+
import { tryHandler } from "../utils/handler.js";
|
|
6
|
+
import { slimView, slimPage } from "../utils/slim.js";
|
|
7
|
+
import { mapWithConcurrency } from "../dispatch/concurrency.js";
|
|
8
|
+
import { WHERE_SCHEMA, compileWhere } from "../schema/filter-dsl.js";
|
|
9
|
+
import { asSdk, } from "../utils/notion-types.js";
|
|
10
|
+
const VERBOSE = z.boolean().optional();
|
|
11
|
+
// Hydration fans out one pages.retrieve / views.retrieve per id. The dispatch
|
|
12
|
+
// rate limiter only gates the operation as a whole, not these inner calls, so
|
|
13
|
+
// keep the fan-out gentle (matches the dispatch batch default) to avoid bursting
|
|
14
|
+
// past Notion's rate limit — a 429 here would surface as a hydration miss.
|
|
15
|
+
const HYDRATE_CONCURRENCY = 3;
|
|
16
|
+
const VIEW_TYPES = [
|
|
17
|
+
"table",
|
|
18
|
+
"board",
|
|
19
|
+
"list",
|
|
20
|
+
"calendar",
|
|
21
|
+
"timeline",
|
|
22
|
+
"gallery",
|
|
23
|
+
"form",
|
|
24
|
+
"chart",
|
|
25
|
+
"map",
|
|
26
|
+
"dashboard",
|
|
27
|
+
];
|
|
28
|
+
// View types whose SDK config carries a required field — we require an explicit
|
|
29
|
+
// `configuration` so the call fails locally with a fix instead of a raw API 400.
|
|
30
|
+
const REQUIRES_CONFIG = {
|
|
31
|
+
calendar: "date_property_id (calendar views group rows by a date property)",
|
|
32
|
+
timeline: "a timeline date/range configuration",
|
|
33
|
+
board: "group_by (board views group by a property)",
|
|
34
|
+
chart: "chart axes/aggregation configuration",
|
|
35
|
+
map: "a location property configuration",
|
|
36
|
+
};
|
|
37
|
+
// Resolve the (data_source_id, database_id) pair a view is created under.
|
|
38
|
+
// Notion's createView requires BOTH in the body — the data source it targets
|
|
39
|
+
// and the database it lives in — even though the SDK types database_id as
|
|
40
|
+
// optional. We accept either input and look up the other.
|
|
41
|
+
async function resolveViewTarget(notion, database_id, data_source_id) {
|
|
42
|
+
if (!database_id && !data_source_id) {
|
|
43
|
+
return {
|
|
44
|
+
error: {
|
|
45
|
+
code: "missing_target",
|
|
46
|
+
message: "Pass data_source_id or database_id.",
|
|
47
|
+
fix: "Provide data_source_id (preferred) or database_id.",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// data_source given without its database → look up the parent database.
|
|
52
|
+
if (data_source_id && !database_id) {
|
|
53
|
+
const ds = await notion.dataSources.retrieve({ data_source_id });
|
|
54
|
+
const parent = ds.parent;
|
|
55
|
+
const dbId = parent?.type === "database_id" ? parent.database_id : undefined;
|
|
56
|
+
if (!dbId) {
|
|
57
|
+
return {
|
|
58
|
+
error: {
|
|
59
|
+
code: "no_parent_database",
|
|
60
|
+
message: `Could not resolve the parent database of data source ${data_source_id}.`,
|
|
61
|
+
fix: "Pass database_id explicitly alongside data_source_id.",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { data_source_id, database_id: dbId };
|
|
66
|
+
}
|
|
67
|
+
// Both given → use as-is.
|
|
68
|
+
if (data_source_id && database_id) {
|
|
69
|
+
return { data_source_id, database_id };
|
|
70
|
+
}
|
|
71
|
+
// database given without a data source → resolve its single data source.
|
|
72
|
+
const db = await notion.databases.retrieve({ database_id: database_id });
|
|
73
|
+
const sources = isFullDatabase(db) ? db.data_sources : [];
|
|
74
|
+
if (sources.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
error: {
|
|
77
|
+
code: "no_data_source",
|
|
78
|
+
message: `Database ${database_id} has no data sources.`,
|
|
79
|
+
fix: "Pass data_source_id directly, or check the database in Notion.",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (sources.length > 1) {
|
|
84
|
+
return {
|
|
85
|
+
error: {
|
|
86
|
+
code: "multi_source_database",
|
|
87
|
+
message: `Database ${database_id} has ${sources.length} data sources.`,
|
|
88
|
+
fix: `Pass data_source_id. Available: ${sources.map((s) => s.id).join(", ")}.`,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { data_source_id: sources[0].id, database_id };
|
|
93
|
+
}
|
|
94
|
+
// Compile the typed `where` DSL into a Notion filter, or pass `filter` through.
|
|
95
|
+
function compileViewFilter(where, filter) {
|
|
96
|
+
if (where !== undefined && filter !== undefined) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: {
|
|
100
|
+
code: "filter_conflict",
|
|
101
|
+
message: "Pass `where` (typed DSL) or `filter` (raw JSON), not both.",
|
|
102
|
+
fix: "Use exactly one of `where` or `filter`.",
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (where !== undefined) {
|
|
107
|
+
try {
|
|
108
|
+
return { ok: true, filter: compileWhere(where) };
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
error: {
|
|
114
|
+
code: "where_compile_error",
|
|
115
|
+
message: err instanceof Error ? err.message : String(err),
|
|
116
|
+
fix: "Check the `where` clause shape, or fall back to raw `filter`.",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (filter !== undefined)
|
|
122
|
+
return { ok: true, filter };
|
|
123
|
+
return { ok: true };
|
|
124
|
+
}
|
|
125
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
126
|
+
// get_view
|
|
127
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
128
|
+
const GetViewParams = z.object({
|
|
129
|
+
view_id: z.string().describe("View ID to retrieve."),
|
|
130
|
+
verbose: VERBOSE,
|
|
131
|
+
});
|
|
132
|
+
register({
|
|
133
|
+
name: "get_view",
|
|
134
|
+
access: "read",
|
|
135
|
+
domain: "views",
|
|
136
|
+
description: "Retrieve a single database view's configuration (name, type, filter, sorts, layout).",
|
|
137
|
+
batchable: true,
|
|
138
|
+
schema: GetViewParams,
|
|
139
|
+
example: { view_id: "<view-id>" },
|
|
140
|
+
handler: tryHandler(async ({ view_id, verbose }) => {
|
|
141
|
+
const notion = await getClient();
|
|
142
|
+
const view = await notion.views.retrieve({ view_id });
|
|
143
|
+
return { ok: true, data: slimView(view, verbose ?? false) };
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
147
|
+
// list_views
|
|
148
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
149
|
+
const ListViewsParams = z
|
|
150
|
+
.object({
|
|
151
|
+
database_id: z.string().optional().describe("List views under this database."),
|
|
152
|
+
data_source_id: z.string().optional().describe("List views under this data source."),
|
|
153
|
+
start_cursor: z.string().optional(),
|
|
154
|
+
page_size: z.number().min(1).max(100).optional(),
|
|
155
|
+
hydrate: z
|
|
156
|
+
.boolean()
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("Fetch each view's name/type (default true). Set false for a cheap id-only list."),
|
|
159
|
+
verbose: VERBOSE,
|
|
160
|
+
})
|
|
161
|
+
.refine((v) => Boolean(v.database_id) || Boolean(v.data_source_id), {
|
|
162
|
+
message: "Pass database_id or data_source_id.",
|
|
163
|
+
});
|
|
164
|
+
register({
|
|
165
|
+
name: "list_views",
|
|
166
|
+
access: "read",
|
|
167
|
+
domain: "views",
|
|
168
|
+
description: "List views under a database or data source. Hydrates id-only refs to {id,name,type} by default.",
|
|
169
|
+
batchable: false,
|
|
170
|
+
schema: ListViewsParams,
|
|
171
|
+
example: { database_id: "<database-id>" },
|
|
172
|
+
handler: tryHandler(async ({ database_id, data_source_id, start_cursor, page_size, hydrate, verbose }) => {
|
|
173
|
+
const notion = await getClient();
|
|
174
|
+
const list = await notion.views.list({
|
|
175
|
+
...(database_id ? { database_id } : {}),
|
|
176
|
+
...(data_source_id ? { data_source_id } : {}),
|
|
177
|
+
...(start_cursor ? { start_cursor } : {}),
|
|
178
|
+
...(page_size ? { page_size } : {}),
|
|
179
|
+
});
|
|
180
|
+
const refs = (list.results ?? []);
|
|
181
|
+
const envelope = {
|
|
182
|
+
has_more: list.has_more ?? false,
|
|
183
|
+
next_cursor: list.next_cursor ?? null,
|
|
184
|
+
};
|
|
185
|
+
if (hydrate === false) {
|
|
186
|
+
return { ok: true, data: { results: refs.map((r) => ({ id: r.id })), ...envelope } };
|
|
187
|
+
}
|
|
188
|
+
const results = await mapWithConcurrency(refs, HYDRATE_CONCURRENCY, async (ref) => {
|
|
189
|
+
const view = await notion.views.retrieve({ view_id: ref.id });
|
|
190
|
+
return slimView(view, verbose ?? false);
|
|
191
|
+
});
|
|
192
|
+
return { ok: true, data: { results, ...envelope } };
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
196
|
+
// query_view
|
|
197
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
198
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
199
|
+
const MAX_PAGE_SIZE = 100;
|
|
200
|
+
const DEFAULT_ITEM_LIMIT = 1000;
|
|
201
|
+
const MAX_ITEM_LIMIT = 1000;
|
|
202
|
+
const QueryViewParams = z.object({
|
|
203
|
+
view_id: z.string().describe("View ID. Executes the view's stored filters/sorts server-side."),
|
|
204
|
+
page_size: z.number().min(1).max(MAX_PAGE_SIZE).optional(),
|
|
205
|
+
paginate: z.boolean().optional().describe("Walk all result pages, up to page_limit rows."),
|
|
206
|
+
page_limit: z
|
|
207
|
+
.number()
|
|
208
|
+
.min(1)
|
|
209
|
+
.max(MAX_ITEM_LIMIT)
|
|
210
|
+
.optional()
|
|
211
|
+
.describe(`Max rows when paginate:true (default ${DEFAULT_ITEM_LIMIT}).`),
|
|
212
|
+
hydrate: z
|
|
213
|
+
.boolean()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe("Fetch full row data for each result (default true). Set false to return ordered ids only."),
|
|
216
|
+
verbose: VERBOSE,
|
|
217
|
+
});
|
|
218
|
+
register({
|
|
219
|
+
name: "query_view",
|
|
220
|
+
access: "read",
|
|
221
|
+
domain: "views",
|
|
222
|
+
description: "Query a view: runs its stored filters/sorts and returns the matching rows. Hydrates row data by default.",
|
|
223
|
+
batchable: false,
|
|
224
|
+
schema: QueryViewParams,
|
|
225
|
+
example: { view_id: "<view-id>", page_size: 50 },
|
|
226
|
+
handler: tryHandler(async ({ view_id, page_size, paginate, page_limit, hydrate, verbose, }) => {
|
|
227
|
+
const notion = await getClient();
|
|
228
|
+
const pageSize = page_size ?? DEFAULT_PAGE_SIZE;
|
|
229
|
+
const doHydrate = hydrate !== false;
|
|
230
|
+
const hydrateIds = async (refs) => {
|
|
231
|
+
if (!doHydrate)
|
|
232
|
+
return refs.map((r) => ({ id: r.id }));
|
|
233
|
+
return mapWithConcurrency(refs, HYDRATE_CONCURRENCY, async (ref) => {
|
|
234
|
+
try {
|
|
235
|
+
const page = await notion.pages.retrieve({ page_id: ref.id });
|
|
236
|
+
return slimPage(page, verbose ?? false, true);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// A row deleted between query and hydrate — surface the id, keep going.
|
|
240
|
+
return { id: ref.id, _hydration_failed: true };
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
const first = (await notion.views.queries.create(asSdk({ view_id, page_size: pageSize })));
|
|
245
|
+
const queryId = first.id;
|
|
246
|
+
const totalCount = first.total_count;
|
|
247
|
+
if (!paginate) {
|
|
248
|
+
const rows = await hydrateIds(first.results ?? []);
|
|
249
|
+
const truncated = first.request_status?.type === "incomplete";
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
data: {
|
|
253
|
+
...(totalCount !== undefined ? { total_count: totalCount } : {}),
|
|
254
|
+
results: rows,
|
|
255
|
+
has_more: first.has_more ?? false,
|
|
256
|
+
next_cursor: first.next_cursor ?? null,
|
|
257
|
+
...(truncated ? { truncated: true } : {}),
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// paginate: accumulate refs across pages up to page_limit, then hydrate once.
|
|
262
|
+
const limit = page_limit ?? DEFAULT_ITEM_LIMIT;
|
|
263
|
+
const refs = [];
|
|
264
|
+
let page = first;
|
|
265
|
+
let pagesWalked = 1;
|
|
266
|
+
let incomplete = first.request_status?.type === "incomplete";
|
|
267
|
+
for (const r of page.results ?? []) {
|
|
268
|
+
if (refs.length >= limit)
|
|
269
|
+
break;
|
|
270
|
+
refs.push({ id: r.id });
|
|
271
|
+
}
|
|
272
|
+
while (refs.length < limit && page.has_more && page.next_cursor) {
|
|
273
|
+
page = (await notion.views.queries.results(asSdk({
|
|
274
|
+
view_id,
|
|
275
|
+
query_id: queryId,
|
|
276
|
+
start_cursor: page.next_cursor,
|
|
277
|
+
page_size: pageSize,
|
|
278
|
+
})));
|
|
279
|
+
pagesWalked += 1;
|
|
280
|
+
if (page.request_status?.type === "incomplete")
|
|
281
|
+
incomplete = true;
|
|
282
|
+
for (const r of page.results ?? []) {
|
|
283
|
+
if (refs.length >= limit)
|
|
284
|
+
break;
|
|
285
|
+
refs.push({ id: r.id });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Best-effort cleanup of the server-side query job.
|
|
289
|
+
try {
|
|
290
|
+
await notion.views.queries.delete(asSdk({ view_id, query_id: queryId }));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* ignore cleanup failures */
|
|
294
|
+
}
|
|
295
|
+
const rows = await hydrateIds(refs);
|
|
296
|
+
const truncated = incomplete || (Boolean(page.has_more) && refs.length >= limit);
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
data: {
|
|
300
|
+
...(totalCount !== undefined ? { total_count: totalCount } : {}),
|
|
301
|
+
results: rows,
|
|
302
|
+
truncated,
|
|
303
|
+
pages_walked: pagesWalked,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
309
|
+
// create_view
|
|
310
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
311
|
+
const CreateViewParams = z.object({
|
|
312
|
+
data_source_id: z.string().optional(),
|
|
313
|
+
database_id: z
|
|
314
|
+
.string()
|
|
315
|
+
.optional()
|
|
316
|
+
.describe("Single-source databases are auto-resolved; multi-source require data_source_id."),
|
|
317
|
+
name: z.string().describe("View name."),
|
|
318
|
+
type: z.enum(VIEW_TYPES).describe("View type."),
|
|
319
|
+
where: WHERE_SCHEMA.optional().describe("Typed filter DSL (same shape as query_database `where`). Mutually exclusive with `filter`."),
|
|
320
|
+
filter: z.unknown().optional().describe("Raw Notion view filter JSON. Mutually exclusive with `where`."),
|
|
321
|
+
sorts: z.array(z.unknown()).optional(),
|
|
322
|
+
configuration: z
|
|
323
|
+
.unknown()
|
|
324
|
+
.optional()
|
|
325
|
+
.describe("Type-specific layout/grouping config (required for calendar/board/timeline/chart/map)."),
|
|
326
|
+
verbose: VERBOSE,
|
|
327
|
+
});
|
|
328
|
+
register({
|
|
329
|
+
name: "create_view",
|
|
330
|
+
access: "write",
|
|
331
|
+
domain: "views",
|
|
332
|
+
description: "Create a database view. table/list/gallery/form need only name+type; calendar/board/timeline/chart/map require `configuration`.",
|
|
333
|
+
batchable: true,
|
|
334
|
+
schema: CreateViewParams,
|
|
335
|
+
example: {
|
|
336
|
+
data_source_id: "<data-source-id>",
|
|
337
|
+
name: "Open Tasks",
|
|
338
|
+
type: "table",
|
|
339
|
+
where: { Status: "Open" },
|
|
340
|
+
},
|
|
341
|
+
rollback: async (data) => {
|
|
342
|
+
const id = data?.id;
|
|
343
|
+
if (!id)
|
|
344
|
+
return;
|
|
345
|
+
const notion = await getClient();
|
|
346
|
+
try {
|
|
347
|
+
await notion.views.delete({ view_id: id });
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
/* ignore */
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
handler: tryHandler(async ({ data_source_id, database_id, name, type, where, filter, sorts, configuration, verbose }) => {
|
|
354
|
+
if (REQUIRES_CONFIG[type] && configuration === undefined) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: {
|
|
358
|
+
code: "missing_view_config",
|
|
359
|
+
message: `A ${type} view requires \`configuration\`.`,
|
|
360
|
+
fix: `Pass \`configuration\` with ${REQUIRES_CONFIG[type]}.`,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// Validate inputs (filter shape) before any network call, so bad input
|
|
365
|
+
// surfaces without a round-trip and regardless of target resolution.
|
|
366
|
+
const compiled = compileViewFilter(where, filter);
|
|
367
|
+
if (!compiled.ok)
|
|
368
|
+
return { ok: false, error: compiled.error };
|
|
369
|
+
const notion = await getClient();
|
|
370
|
+
const resolved = await resolveViewTarget(notion, database_id, data_source_id);
|
|
371
|
+
if (resolved.error)
|
|
372
|
+
return { ok: false, error: resolved.error };
|
|
373
|
+
const body = {
|
|
374
|
+
data_source_id: resolved.data_source_id,
|
|
375
|
+
// createView requires database_id in the body (SDK types it optional, but
|
|
376
|
+
// the API rejects a body without it — confirmed against the live API).
|
|
377
|
+
database_id: resolved.database_id,
|
|
378
|
+
name,
|
|
379
|
+
type,
|
|
380
|
+
...(compiled.filter !== undefined ? { filter: compiled.filter } : {}),
|
|
381
|
+
...(sorts !== undefined ? { sorts } : {}),
|
|
382
|
+
...(configuration !== undefined ? { configuration } : {}),
|
|
383
|
+
};
|
|
384
|
+
const view = await notion.views.create(asSdk(body));
|
|
385
|
+
return { ok: true, data: slimView(view, verbose ?? false) };
|
|
386
|
+
}),
|
|
387
|
+
});
|
|
388
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
389
|
+
// update_view
|
|
390
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
391
|
+
const UpdateViewParams = z.object({
|
|
392
|
+
view_id: z.string(),
|
|
393
|
+
name: z.string().optional(),
|
|
394
|
+
where: WHERE_SCHEMA.optional().describe("Replace the view filter (typed DSL). Mutually exclusive with `filter`."),
|
|
395
|
+
filter: z.unknown().optional().describe("Replace the view filter (raw JSON). Mutually exclusive with `where`."),
|
|
396
|
+
sorts: z.array(z.unknown()).optional(),
|
|
397
|
+
configuration: z.unknown().optional(),
|
|
398
|
+
clear: z
|
|
399
|
+
.array(z.enum(["filter", "sorts", "configuration"]))
|
|
400
|
+
.optional()
|
|
401
|
+
.describe("Fields to clear (set to null)."),
|
|
402
|
+
verbose: VERBOSE,
|
|
403
|
+
});
|
|
404
|
+
register({
|
|
405
|
+
name: "update_view",
|
|
406
|
+
access: "write",
|
|
407
|
+
domain: "views",
|
|
408
|
+
description: "Update a view's name/filter/sorts/configuration. Use `clear` to remove filter/sorts/configuration.",
|
|
409
|
+
batchable: true,
|
|
410
|
+
schema: UpdateViewParams,
|
|
411
|
+
example: { view_id: "<view-id>", name: "Renamed" },
|
|
412
|
+
handler: tryHandler(async ({ view_id, name, where, filter, sorts, configuration, clear, verbose }) => {
|
|
413
|
+
const compiled = compileViewFilter(where, filter);
|
|
414
|
+
if (!compiled.ok)
|
|
415
|
+
return { ok: false, error: compiled.error };
|
|
416
|
+
const toClear = new Set(clear ?? []);
|
|
417
|
+
const body = { view_id };
|
|
418
|
+
if (name !== undefined)
|
|
419
|
+
body.name = name;
|
|
420
|
+
if (compiled.filter !== undefined)
|
|
421
|
+
body.filter = compiled.filter;
|
|
422
|
+
if (sorts !== undefined)
|
|
423
|
+
body.sorts = sorts;
|
|
424
|
+
if (configuration !== undefined)
|
|
425
|
+
body.configuration = configuration;
|
|
426
|
+
if (toClear.has("filter"))
|
|
427
|
+
body.filter = null;
|
|
428
|
+
if (toClear.has("sorts"))
|
|
429
|
+
body.sorts = null;
|
|
430
|
+
if (toClear.has("configuration"))
|
|
431
|
+
body.configuration = null;
|
|
432
|
+
const notion = await getClient();
|
|
433
|
+
const view = await notion.views.update(asSdk(body));
|
|
434
|
+
return { ok: true, data: slimView(view, verbose ?? false) };
|
|
435
|
+
}),
|
|
436
|
+
});
|
|
437
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
438
|
+
// delete_view
|
|
439
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
440
|
+
const DeleteViewParams = z.object({ view_id: z.string() });
|
|
441
|
+
register({
|
|
442
|
+
name: "delete_view",
|
|
443
|
+
access: "write",
|
|
444
|
+
domain: "views",
|
|
445
|
+
destructive: true,
|
|
446
|
+
description: "Delete a database view. Irreversible. Honors NOTION_READ_ONLY and the operation allow/block lists.",
|
|
447
|
+
batchable: true,
|
|
448
|
+
schema: DeleteViewParams,
|
|
449
|
+
example: { view_id: "<view-id>" },
|
|
450
|
+
handler: tryHandler(async ({ view_id }) => {
|
|
451
|
+
const notion = await getClient();
|
|
452
|
+
const result = await notion.views.delete({ view_id });
|
|
453
|
+
const r = result;
|
|
454
|
+
return { ok: true, data: { id: r.id ?? view_id, deleted: r.deleted ?? true } };
|
|
455
|
+
}),
|
|
456
|
+
});
|
package/build/schema/blocks.js
CHANGED
|
@@ -21,6 +21,7 @@ export const BASE_BLOCK_REQUEST_SCHEMA = z.object({
|
|
|
21
21
|
.optional()
|
|
22
22
|
.describe("Whether block has child blocks"),
|
|
23
23
|
archived: z.boolean().optional().describe("Whether block is archived"),
|
|
24
|
+
in_trash: z.boolean().optional().describe("Whether block is in trash (2026-03-11 surface)"),
|
|
24
25
|
});
|
|
25
26
|
export const TEXT_BLOCK_BASE_REQUEST_SCHEMA = z.object({
|
|
26
27
|
rich_text: z
|
package/build/services/notion.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import { Client } from "@notionhq/client";
|
|
2
|
+
import nodeFetch from "node-fetch";
|
|
3
|
+
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
2
4
|
import { authProvider } from "./auth.js";
|
|
3
5
|
let cachedClient = null;
|
|
4
6
|
let cachedToken = null;
|
|
7
|
+
// Route the Notion SDK's HTTP calls through an HTTP(S) proxy when one is
|
|
8
|
+
// configured via the standard env vars. node-fetch is used (instead of global
|
|
9
|
+
// fetch) because it accepts a custom `agent`. When no proxy is set we still go
|
|
10
|
+
// through node-fetch so behavior is uniform.
|
|
11
|
+
const proxyFetch = (url, init) => {
|
|
12
|
+
const proxyURL = process.env.HTTPS_PROXY ||
|
|
13
|
+
process.env.https_proxy ||
|
|
14
|
+
process.env.HTTP_PROXY ||
|
|
15
|
+
process.env.http_proxy ||
|
|
16
|
+
null;
|
|
17
|
+
if (!proxyURL)
|
|
18
|
+
return nodeFetch(url, init);
|
|
19
|
+
return nodeFetch(url, { ...init, agent: new HttpsProxyAgent(proxyURL) });
|
|
20
|
+
};
|
|
5
21
|
export async function getClient() {
|
|
6
22
|
const token = await authProvider.getToken();
|
|
7
23
|
if (token !== cachedToken || cachedClient === null) {
|
|
8
24
|
const fresh = new Client({
|
|
9
25
|
auth: token,
|
|
10
|
-
notionVersion: "
|
|
26
|
+
notionVersion: "2026-03-11",
|
|
27
|
+
fetch: proxyFetch,
|
|
11
28
|
});
|
|
12
29
|
cachedClient = fresh;
|
|
13
30
|
cachedToken = token;
|
package/build/tools/index.js
CHANGED
|
@@ -138,12 +138,13 @@ function renderOperationsIndex() {
|
|
|
138
138
|
for (const def of enabledOperations()) {
|
|
139
139
|
lines.push(`| \`${def.name}\` | ${def.batchable ? "yes" : "no"} | ${def.description} |`);
|
|
140
140
|
}
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
141
|
+
// Document the WHERE DSL when any operation that accepts it is enabled.
|
|
142
|
+
// query_database, create_view, and update_view all take the same `where`.
|
|
143
|
+
const whereOps = ["query_database", "create_view", "update_view"].filter((op) => isOperationAllowed(op));
|
|
144
|
+
if (whereOps.length === 0) {
|
|
144
145
|
return lines.join("\n");
|
|
145
146
|
}
|
|
146
|
-
lines.push("", "##
|
|
147
|
-
lines.push(
|
|
147
|
+
lines.push("", "## WHERE filter DSL", "");
|
|
148
|
+
lines.push(`The same \`where\` DSL is accepted by ${whereOps.map((o) => `\`${o}\``).join(", ")}. It is a compact shorthand 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`.");
|
|
148
149
|
return lines.join("\n");
|
|
149
150
|
}
|
package/build/utils/slim.js
CHANGED
|
@@ -205,6 +205,21 @@ export function slimDataSource(ds, verbose = false) {
|
|
|
205
205
|
...(ds.in_trash ? { in_trash: true } : {}),
|
|
206
206
|
};
|
|
207
207
|
}
|
|
208
|
+
// View objects are loosely typed on the SDK surface, so accept `unknown` and
|
|
209
|
+
// narrow defensively. Default keeps the high-signal fields (id/name/type and
|
|
210
|
+
// any filter/sorts); the bulky type-specific `configuration` is verbose-only.
|
|
211
|
+
export function slimView(view, verbose = false) {
|
|
212
|
+
if (verbose)
|
|
213
|
+
return view;
|
|
214
|
+
const v = (view ?? {});
|
|
215
|
+
return {
|
|
216
|
+
id: v.id,
|
|
217
|
+
...(v.name !== undefined ? { name: v.name } : {}),
|
|
218
|
+
...(v.type !== undefined ? { type: v.type } : {}),
|
|
219
|
+
...(v.filter ? { filter: v.filter } : {}),
|
|
220
|
+
...(Array.isArray(v.sorts) && v.sorts.length ? { sorts: v.sorts } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
208
223
|
export function slimItem(item, verbose = false, includeProperties = false) {
|
|
209
224
|
if (verbose)
|
|
210
225
|
return item;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "notion-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"notion-mcp-server": "build/index.js"
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
45
|
"@notionhq/client": "^5.22.0",
|
|
46
|
+
"https-proxy-agent": "9.1.0",
|
|
47
|
+
"node-fetch": "3.3.2",
|
|
46
48
|
"remark-gfm": "^4.0.1",
|
|
47
49
|
"remark-parse": "^11.0.0",
|
|
48
50
|
"unified": "^11.0.5",
|
|
@@ -50,9 +52,9 @@
|
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"@types/mdast": "^4.0.4",
|
|
53
|
-
"@types/node": "^
|
|
54
|
-
"shx": "^0.
|
|
55
|
-
"typescript": "^
|
|
55
|
+
"@types/node": "^25.9.3",
|
|
56
|
+
"shx": "^0.4.0",
|
|
57
|
+
"typescript": "^6.0.3",
|
|
56
58
|
"vitest": "^4.1.7"
|
|
57
59
|
},
|
|
58
60
|
"engines": {
|