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 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 (35 ops, plus one alias)
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` |
@@ -25,6 +25,7 @@ const DOMAIN_GROUPS = [
25
25
  "comments",
26
26
  "users",
27
27
  "files",
28
+ "views",
28
29
  ];
29
30
  /** Expand a single token to op names, or null if it matches no group and no op. */
30
31
  function expandToken(token, ops) {
@@ -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
- ...(archived !== undefined ? { archived } : {}),
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) };
@@ -11,6 +11,7 @@ export async function initOperations() {
11
11
  import("./blocks.js"),
12
12
  import("./databases.js"),
13
13
  import("./data-sources.js"),
14
+ import("./views.js"),
14
15
  import("./comments.js"),
15
16
  import("./users.js"),
16
17
  import("./files.js"),
@@ -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
+ });
@@ -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
@@ -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: "2025-09-03",
26
+ notionVersion: "2026-03-11",
27
+ fetch: proxyFetch,
11
28
  });
12
29
  cachedClient = fresh;
13
30
  cachedToken = token;
@@ -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
- // Only document the query_database filter DSL when that op is actually enabled
142
- // otherwise the menu advertises a disabled operation.
143
- if (!isOperationAllowed("query_database")) {
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("", "## `query_database` WHERE DSL", "");
147
- 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`.");
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
  }
@@ -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.7.0",
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": "^22.13.10",
54
- "shx": "^0.3.4",
55
- "typescript": "^5.8.2",
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": {