notion-mcp-server 1.0.1 → 2.4.2

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.
Files changed (99) hide show
  1. package/README.md +383 -192
  2. package/build/config/index.js +3 -1
  3. package/build/dispatch/concurrency.js +15 -0
  4. package/build/dispatch/idempotency.js +38 -0
  5. package/build/dispatch/index.js +175 -0
  6. package/build/dispatch/rate-limit.js +56 -0
  7. package/build/dispatch/retry.js +97 -0
  8. package/build/index.js +1 -1
  9. package/build/markdown/parse.js +265 -0
  10. package/build/operations/blocks.js +331 -0
  11. package/build/operations/comments.js +191 -0
  12. package/build/operations/data-sources.js +85 -0
  13. package/build/operations/databases.js +345 -0
  14. package/build/operations/files.js +239 -0
  15. package/build/operations/index.js +19 -0
  16. package/build/operations/pages.js +486 -0
  17. package/build/operations/registry.js +16 -0
  18. package/build/operations/users.js +101 -0
  19. package/build/prompts/index.js +105 -0
  20. package/build/schema/blocks.js +19 -138
  21. package/build/schema/database.js +27 -111
  22. package/build/schema/emit.js +68 -0
  23. package/build/schema/file.js +1 -1
  24. package/build/schema/filter-dsl.js +333 -0
  25. package/build/schema/icon.js +1 -1
  26. package/build/schema/page-properties.js +17 -3
  27. package/build/schema/page.js +12 -125
  28. package/build/schema/refs.js +16 -0
  29. package/build/schema/rich-text.js +1 -1
  30. package/build/server/index.js +16 -3
  31. package/build/services/auth.js +19 -0
  32. package/build/services/notion.js +14 -17
  33. package/build/tools/index.js +119 -21
  34. package/build/utils/error.js +125 -86
  35. package/build/utils/handler.js +11 -0
  36. package/build/utils/learning-error.js +40 -0
  37. package/build/utils/notion-types.js +16 -0
  38. package/build/utils/paginate.js +35 -0
  39. package/build/utils/schema-slice.js +156 -0
  40. package/build/utils/slim.js +269 -0
  41. package/package.json +13 -7
  42. package/build/resources/imageList.js +0 -62
  43. package/build/resources/index.js +0 -1
  44. package/build/resources/predictionList.js +0 -43
  45. package/build/resources/svgList.js +0 -69
  46. package/build/schema/comments.js +0 -60
  47. package/build/schema/notion.js +0 -57
  48. package/build/schema/richText.js +0 -757
  49. package/build/schema/tools.js +0 -17
  50. package/build/schema/users.js +0 -39
  51. package/build/services/loggs.js +0 -13
  52. package/build/services/replicate.js +0 -23
  53. package/build/tools/appendBlockChildren.js +0 -25
  54. package/build/tools/batchAppendBlockChildren.js +0 -33
  55. package/build/tools/batchDeleteBlocks.js +0 -32
  56. package/build/tools/batchMixedOperations.js +0 -58
  57. package/build/tools/batchUpdateBlocks.js +0 -33
  58. package/build/tools/blocks.js +0 -34
  59. package/build/tools/comments.js +0 -81
  60. package/build/tools/createDatabase.js +0 -18
  61. package/build/tools/createPage.js +0 -18
  62. package/build/tools/createPrediction.js +0 -28
  63. package/build/tools/database.js +0 -16
  64. package/build/tools/deleteBlock.js +0 -24
  65. package/build/tools/formatRichText.js +0 -83
  66. package/build/tools/generateImage.js +0 -48
  67. package/build/tools/generateImageVariants.js +0 -105
  68. package/build/tools/generateMultipleImages.js +0 -60
  69. package/build/tools/generateSVG.js +0 -43
  70. package/build/tools/getPrediction.js +0 -22
  71. package/build/tools/pages.js +0 -22
  72. package/build/tools/predictionList.js +0 -30
  73. package/build/tools/queryDatabase.js +0 -22
  74. package/build/tools/retrieveBlock.js +0 -24
  75. package/build/tools/retrieveBlockChildren.js +0 -32
  76. package/build/tools/searchPage.js +0 -24
  77. package/build/tools/updateBlock.js +0 -25
  78. package/build/tools/updateDatabase.js +0 -18
  79. package/build/tools/updatePage.js +0 -40
  80. package/build/tools/updatePageProperties.js +0 -21
  81. package/build/tools/users.js +0 -75
  82. package/build/types/blocks.js +0 -12
  83. package/build/types/comments.js +0 -7
  84. package/build/types/database.js +0 -6
  85. package/build/types/notion.js +0 -1
  86. package/build/types/page.js +0 -8
  87. package/build/types/richText.js +0 -1
  88. package/build/types/tools.js +0 -1
  89. package/build/types/users.js +0 -6
  90. package/build/utils/blob.js +0 -5
  91. package/build/utils/image.js +0 -34
  92. package/build/utils/index.js +0 -1
  93. package/build/utils/richText.js +0 -174
  94. package/build/validation/blocks.js +0 -568
  95. package/build/validation/notion.js +0 -51
  96. package/build/validation/page.js +0 -262
  97. package/build/validation/richText.js +0 -744
  98. package/build/validation/tools.js +0 -16
  99. /package/build/{types/index.js → operations/types.js} +0 -0
@@ -0,0 +1,331 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../services/notion.js";
3
+ import { register } from "./registry.js";
4
+ import { tryHandler } from "../utils/handler.js";
5
+ import { slimBlock, slimList } from "../utils/slim.js";
6
+ import { parseMarkdownToBlocks } from "../markdown/parse.js";
7
+ import { TEXT_BLOCK_REQUEST_SCHEMA } from "../schema/blocks.js";
8
+ import { asSdk, } from "../utils/notion-types.js";
9
+ const VERBOSE = z.boolean().optional();
10
+ // ──────────────────────────────────────────────────────────────────────────
11
+ // append_blocks
12
+ // ──────────────────────────────────────────────────────────────────────────
13
+ const AppendBlocksParams = z
14
+ .object({
15
+ block_id: z.string().describe("Parent page ID or block ID to append into."),
16
+ markdown: z.string().optional().describe("Content to append, as markdown. Parsed server-side."),
17
+ children: z.array(z.unknown()).optional().describe("Structured Notion blocks. Mutually exclusive with markdown."),
18
+ after: z.string().optional().describe("Append immediately after this block ID (legacy ordering)."),
19
+ position: z.enum(["start", "end"]).optional().describe("Append at start or end. Preferred over `after`."),
20
+ verbose: VERBOSE,
21
+ })
22
+ .refine((v) => Boolean(v.markdown) !== Boolean(v.children), {
23
+ message: "Pass exactly one of `markdown` or `children`.",
24
+ })
25
+ .refine((v) => !(v.after && v.position), {
26
+ message: "Pass at most one of `after` or `position`.",
27
+ });
28
+ register({
29
+ name: "append_blocks",
30
+ description: "Append children to a page or block. Use markdown for prose content.",
31
+ batchable: true,
32
+ schema: AppendBlocksParams,
33
+ example: {
34
+ block_id: "<page-or-block-id>",
35
+ markdown: "## Section\n\n- bullet 1\n- bullet 2",
36
+ },
37
+ exampleBatch: {
38
+ items: [
39
+ { block_id: "<page-id-1>", markdown: "Body 1" },
40
+ { block_id: "<page-id-2>", markdown: "Body 2" },
41
+ ],
42
+ },
43
+ handler: tryHandler(async ({ block_id, markdown, children, after, position, verbose }) => {
44
+ const blocks = markdown ? parseMarkdownToBlocks(markdown) : (children ?? []);
45
+ if (blocks.length === 0) {
46
+ return {
47
+ ok: false,
48
+ error: {
49
+ code: "empty_content",
50
+ message: "No blocks to append (markdown parsed to empty, or children array is empty).",
51
+ },
52
+ };
53
+ }
54
+ const positionArg = position
55
+ ? { type: position }
56
+ : after
57
+ ? { type: "after_block", after_block: { id: after } }
58
+ : undefined;
59
+ const notion = await getClient();
60
+ const body = {
61
+ block_id,
62
+ children: asSdk(blocks),
63
+ ...(positionArg ? { position: positionArg } : {}),
64
+ };
65
+ const response = await notion.blocks.children.append(asSdk(body));
66
+ // Notion returns just the new blocks for default/end/after positions, but
67
+ // the full updated child set for `position: "start"` (new blocks appear
68
+ // first). Slice to the requested count so the response stays bounded.
69
+ // If the response is shorter than expected (shouldn't happen, but be safe),
70
+ // surface only what we got and skip ID emission rather than return wrong IDs.
71
+ const canMapIds = response.results.length >= blocks.length;
72
+ const newBlocks = canMapIds
73
+ ? response.results.slice(0, blocks.length)
74
+ : response.results;
75
+ if (verbose) {
76
+ return {
77
+ ok: true,
78
+ data: {
79
+ appended: blocks.length,
80
+ results: newBlocks.map((r) => slimBlock(r, true)),
81
+ },
82
+ };
83
+ }
84
+ return {
85
+ ok: true,
86
+ data: {
87
+ appended: blocks.length,
88
+ ...(canMapIds ? { ids: newBlocks.map((r) => r.id) } : {}),
89
+ },
90
+ };
91
+ }),
92
+ });
93
+ // ──────────────────────────────────────────────────────────────────────────
94
+ // get_block
95
+ // ──────────────────────────────────────────────────────────────────────────
96
+ const GetBlockParams = z.object({
97
+ block_id: z.string().describe("Block ID to retrieve."),
98
+ verbose: VERBOSE,
99
+ });
100
+ register({
101
+ name: "get_block",
102
+ description: "Retrieve a single block by ID (metadata + type-specific body). For its children, use get_block_children.",
103
+ batchable: true,
104
+ schema: GetBlockParams,
105
+ example: { block_id: "<block-id>" },
106
+ exampleBatch: {
107
+ items: [{ block_id: "<block-id-1>" }, { block_id: "<block-id-2>" }],
108
+ },
109
+ handler: tryHandler(async ({ block_id, verbose }) => {
110
+ const notion = await getClient();
111
+ const response = await notion.blocks.retrieve({ block_id });
112
+ return { ok: true, data: slimBlock(response, verbose ?? false) };
113
+ }),
114
+ });
115
+ // ──────────────────────────────────────────────────────────────────────────
116
+ // get_block_children
117
+ // ──────────────────────────────────────────────────────────────────────────
118
+ const GetBlockChildrenParams = z.object({
119
+ block_id: z.string().describe("Page ID or block ID. For a page, returns its top-level blocks."),
120
+ start_cursor: z.string().optional(),
121
+ page_size: z.number().min(1).max(100).optional(),
122
+ verbose: VERBOSE,
123
+ });
124
+ register({
125
+ name: "get_block_children",
126
+ description: "List child blocks under a page or block, paginated.",
127
+ batchable: false,
128
+ schema: GetBlockChildrenParams,
129
+ example: { block_id: "<page-id>", page_size: 100 },
130
+ handler: tryHandler(async ({ block_id, start_cursor, page_size, verbose }) => {
131
+ const notion = await getClient();
132
+ const response = await notion.blocks.children.list({
133
+ block_id,
134
+ start_cursor,
135
+ page_size: page_size ?? 100,
136
+ });
137
+ return { ok: true, data: slimList(response, slimBlock, verbose ?? false) };
138
+ }),
139
+ });
140
+ function extractTypedBody(block) {
141
+ return { [block.type]: block[block.type] };
142
+ }
143
+ // Block-type keys recognized in update_block's structured `data`.
144
+ // When `data` has exactly one of these keys and no explicit `type`,
145
+ // we infer the discriminator so the caller can write {to_do:{...}}
146
+ // instead of {type:"to_do", to_do:{...}}.
147
+ const INFERRABLE_BLOCK_TYPES = new Set([
148
+ "paragraph",
149
+ "heading_1",
150
+ "heading_2",
151
+ "heading_3",
152
+ "heading_4",
153
+ "quote",
154
+ "callout",
155
+ "toggle",
156
+ "bulleted_list_item",
157
+ "numbered_list_item",
158
+ "to_do",
159
+ "code",
160
+ "divider",
161
+ "image",
162
+ "tab",
163
+ ]);
164
+ function inferDataType(input) {
165
+ if (typeof input !== "object" || input === null || Array.isArray(input))
166
+ return input;
167
+ const obj = input;
168
+ if (typeof obj.type === "string")
169
+ return obj;
170
+ const matched = Object.keys(obj).filter((k) => INFERRABLE_BLOCK_TYPES.has(k));
171
+ if (matched.length === 1) {
172
+ return { type: matched[0], ...obj };
173
+ }
174
+ return obj;
175
+ }
176
+ const UpdateBlockParams = z
177
+ .object({
178
+ block_id: z.string(),
179
+ markdown: z
180
+ .string()
181
+ .optional()
182
+ .describe("New content as markdown. Must parse to exactly one block matching the existing block's type."),
183
+ data: z
184
+ .preprocess(inferDataType, TEXT_BLOCK_REQUEST_SCHEMA)
185
+ .optional()
186
+ .describe("Structured block envelope: `{type, <type>: {...}}` (e.g. `{type:\"to_do\", to_do:{rich_text:[...], checked:true}}`). When `type` is omitted, it is inferred from the sole block-type key, so `{to_do:{...}}` works as well. Type must match the existing block."),
187
+ verbose: VERBOSE,
188
+ })
189
+ .refine((v) => Boolean(v.markdown) !== Boolean(v.data), {
190
+ message: "Pass exactly one of `markdown` or `data`.",
191
+ });
192
+ register({
193
+ name: "update_block",
194
+ 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
+ batchable: true,
196
+ schema: UpdateBlockParams,
197
+ example: { block_id: "<block-id>", markdown: "Updated paragraph text." },
198
+ exampleBatch: {
199
+ items: [
200
+ { block_id: "<block-id-1>", markdown: "First update." },
201
+ {
202
+ block_id: "<block-id-2>",
203
+ data: { to_do: { rich_text: [{ type: "text", text: { content: "Done" } }], checked: true } },
204
+ },
205
+ ],
206
+ },
207
+ handler: tryHandler(async ({ block_id, markdown, data, verbose }) => {
208
+ let body;
209
+ if (markdown) {
210
+ const parsed = parseMarkdownToBlocks(markdown);
211
+ if (parsed.length !== 1) {
212
+ return {
213
+ ok: false,
214
+ error: {
215
+ code: "markdown_multiblock",
216
+ message: `Update requires exactly one block; markdown parsed to ${parsed.length}.`,
217
+ fix: "Use append_blocks for multi-block content, or shorten markdown to a single block.",
218
+ },
219
+ };
220
+ }
221
+ body = extractTypedBody(parsed[0]);
222
+ }
223
+ else {
224
+ body = extractTypedBody(data);
225
+ }
226
+ const notion = await getClient();
227
+ const response = await notion.blocks.update(asSdk({ block_id, ...body }));
228
+ return { ok: true, data: slimBlock(response, verbose ?? false) };
229
+ }),
230
+ });
231
+ // ──────────────────────────────────────────────────────────────────────────
232
+ // delete_block
233
+ // ──────────────────────────────────────────────────────────────────────────
234
+ const DeleteBlockParams = z.object({ block_id: z.string(), verbose: VERBOSE });
235
+ register({
236
+ name: "delete_block",
237
+ description: "Archive (soft-delete) a block.",
238
+ batchable: true,
239
+ schema: DeleteBlockParams,
240
+ example: { block_id: "<block-id>" },
241
+ exampleBatch: { items: [{ block_id: "<block-id-1>" }, { block_id: "<block-id-2>" }] },
242
+ handler: tryHandler(async ({ block_id, verbose }) => {
243
+ const notion = await getClient();
244
+ const response = await notion.blocks.delete({ block_id });
245
+ return { ok: true, data: slimBlock(response, verbose ?? false) };
246
+ }),
247
+ });
248
+ // ──────────────────────────────────────────────────────────────────────────
249
+ // batch_mixed_blocks (power-user escape hatch for atomic-ish mixed ops)
250
+ // ──────────────────────────────────────────────────────────────────────────
251
+ const MixedOp = z.discriminatedUnion("op", [
252
+ z.object({
253
+ op: z.literal("append"),
254
+ block_id: z.string(),
255
+ markdown: z.string().optional(),
256
+ children: z.array(z.unknown()).optional(),
257
+ }),
258
+ z.object({
259
+ op: z.literal("update"),
260
+ block_id: z.string(),
261
+ markdown: z.string().optional(),
262
+ data: z.preprocess(inferDataType, TEXT_BLOCK_REQUEST_SCHEMA).optional(),
263
+ }),
264
+ z.object({
265
+ op: z.literal("delete"),
266
+ block_id: z.string(),
267
+ }),
268
+ ]);
269
+ const BatchMixedBlocksParams = z.object({
270
+ operations: z.array(MixedOp).min(1),
271
+ verbose: VERBOSE,
272
+ });
273
+ register({
274
+ name: "batch_mixed_blocks",
275
+ 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
+ batchable: false,
277
+ schema: BatchMixedBlocksParams,
278
+ example: {
279
+ operations: [
280
+ { op: "append", block_id: "<page-id>", markdown: "Header\n" },
281
+ { op: "update", block_id: "<block-id>", markdown: "New text" },
282
+ { op: "delete", block_id: "<other-block-id>" },
283
+ ],
284
+ },
285
+ handler: tryHandler(async ({ operations, verbose }) => {
286
+ const notion = await getClient();
287
+ const results = [];
288
+ for (const op of operations) {
289
+ if (op.op === "append") {
290
+ const blocks = op.markdown
291
+ ? parseMarkdownToBlocks(op.markdown)
292
+ : (op.children ?? []);
293
+ const r = await notion.blocks.children.append(asSdk({
294
+ block_id: op.block_id,
295
+ children: asSdk(blocks),
296
+ }));
297
+ const canMapIds = r.results.length >= blocks.length;
298
+ const newBlocks = canMapIds ? r.results.slice(0, blocks.length) : r.results;
299
+ results.push(verbose
300
+ ? { op: "append", appended: blocks.length, results: newBlocks.map((x) => slimBlock(x, true)) }
301
+ : {
302
+ op: "append",
303
+ appended: blocks.length,
304
+ ...(canMapIds ? { ids: newBlocks.map((x) => x.id) } : {}),
305
+ });
306
+ }
307
+ else if (op.op === "update") {
308
+ let body;
309
+ if (op.markdown) {
310
+ const parsed = parseMarkdownToBlocks(op.markdown);
311
+ if (parsed.length !== 1)
312
+ throw new Error("update markdown must be a single block");
313
+ body = extractTypedBody(parsed[0]);
314
+ }
315
+ else if (op.data) {
316
+ body = extractTypedBody(op.data);
317
+ }
318
+ else {
319
+ throw new Error("update requires markdown or data");
320
+ }
321
+ const r = await notion.blocks.update(asSdk({ block_id: op.block_id, ...body }));
322
+ results.push({ op: "update", block: slimBlock(r, verbose ?? false) });
323
+ }
324
+ else {
325
+ const r = await notion.blocks.delete({ block_id: op.block_id });
326
+ results.push({ op: "delete", block: slimBlock(r, verbose ?? false) });
327
+ }
328
+ }
329
+ return { ok: true, data: { count: results.length, results } };
330
+ }),
331
+ });
@@ -0,0 +1,191 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../services/notion.js";
3
+ import { register } from "./registry.js";
4
+ import { tryHandler } from "../utils/handler.js";
5
+ import { slimComment, slimList } from "../utils/slim.js";
6
+ import { asSdk } from "../utils/notion-types.js";
7
+ import { paginateAll } from "../utils/paginate.js";
8
+ const VERBOSE = z.boolean().optional();
9
+ function plainTextRich(text) {
10
+ return [{ type: "text", text: { content: text } }];
11
+ }
12
+ // ──────────────────────────────────────────────────────────────────────────
13
+ // list_comments
14
+ // ──────────────────────────────────────────────────────────────────────────
15
+ const ListCommentsParams = z.object({
16
+ block_id: z.string().describe("Page or block ID to list comments from."),
17
+ start_cursor: z.string().optional(),
18
+ page_size: z.number().min(1).max(100).optional(),
19
+ paginate: z
20
+ .boolean()
21
+ .optional()
22
+ .describe("Auto-walk all pages and return combined results. Ignores start_cursor when set."),
23
+ page_limit: z
24
+ .number()
25
+ .min(1)
26
+ .max(1000)
27
+ .optional()
28
+ .describe("Max pages to fetch when paginate=true (default 10, ~1000 comments with page_size=100)."),
29
+ verbose: VERBOSE,
30
+ });
31
+ register({
32
+ name: "list_comments",
33
+ description: "List comments on a page or block. Pass paginate:true to auto-walk all pages.",
34
+ batchable: false,
35
+ schema: ListCommentsParams,
36
+ example: { block_id: "<page-id>" },
37
+ handler: tryHandler(async ({ block_id, start_cursor, page_size, paginate, page_limit, verbose }) => {
38
+ const notion = await getClient();
39
+ if (paginate) {
40
+ const { results, truncated, pages_walked } = await paginateAll(async (cursor) => {
41
+ const r = await notion.comments.list({
42
+ block_id,
43
+ start_cursor: cursor,
44
+ page_size: page_size ?? 100,
45
+ });
46
+ return { results: r.results, has_more: r.has_more, next_cursor: r.next_cursor };
47
+ }, { limit: page_limit });
48
+ return {
49
+ ok: true,
50
+ data: {
51
+ results: results.map((c) => slimComment(c, verbose ?? false)),
52
+ truncated,
53
+ pages_walked,
54
+ },
55
+ };
56
+ }
57
+ const response = await notion.comments.list({
58
+ block_id,
59
+ start_cursor,
60
+ page_size: page_size ?? 50,
61
+ });
62
+ return { ok: true, data: slimList(response, slimComment, verbose ?? false) };
63
+ }),
64
+ });
65
+ // ──────────────────────────────────────────────────────────────────────────
66
+ // add_page_comment
67
+ // ──────────────────────────────────────────────────────────────────────────
68
+ const AddPageCommentParams = z
69
+ .object({
70
+ page_id: z.string(),
71
+ text: z.string().optional().describe("Plain-text comment body."),
72
+ markdown: z.string().optional().describe("Comment body as markdown. Mutually exclusive with text."),
73
+ verbose: VERBOSE,
74
+ })
75
+ .refine((v) => Boolean(v.text) !== Boolean(v.markdown), {
76
+ message: "Pass exactly one of `text` or `markdown`.",
77
+ });
78
+ register({
79
+ name: "add_page_comment",
80
+ description: "Add a top-level comment to a page. Body can be plain text or markdown.",
81
+ batchable: true,
82
+ schema: AddPageCommentParams,
83
+ example: { page_id: "<page-id>", text: "Looks good to me." },
84
+ exampleBatch: {
85
+ items: [
86
+ { page_id: "<page-id>", text: "First note." },
87
+ { page_id: "<page-id>", markdown: "**Second** note." },
88
+ ],
89
+ },
90
+ handler: tryHandler(async ({ page_id, text, markdown, verbose }) => {
91
+ const notion = await getClient();
92
+ const body = markdown !== undefined
93
+ ? { parent: { page_id }, markdown }
94
+ : { parent: { page_id }, rich_text: plainTextRich(text) };
95
+ const response = await notion.comments.create(asSdk(body));
96
+ return { ok: true, data: slimComment(response, verbose ?? false) };
97
+ }),
98
+ });
99
+ // ──────────────────────────────────────────────────────────────────────────
100
+ // add_discussion_comment
101
+ // ──────────────────────────────────────────────────────────────────────────
102
+ const AddDiscussionCommentParams = z
103
+ .object({
104
+ discussion_id: z.string(),
105
+ text: z.string().optional(),
106
+ markdown: z.string().optional().describe("Comment body as markdown. Mutually exclusive with text."),
107
+ verbose: VERBOSE,
108
+ })
109
+ .refine((v) => Boolean(v.text) !== Boolean(v.markdown), {
110
+ message: "Pass exactly one of `text` or `markdown`.",
111
+ });
112
+ register({
113
+ name: "add_discussion_comment",
114
+ description: "Reply to an existing discussion thread. Body can be plain text or markdown.",
115
+ batchable: true,
116
+ schema: AddDiscussionCommentParams,
117
+ example: { discussion_id: "<discussion-id>", text: "Thanks for the heads-up." },
118
+ handler: tryHandler(async ({ discussion_id, text, markdown, verbose }) => {
119
+ const notion = await getClient();
120
+ const body = markdown !== undefined
121
+ ? { discussion_id, markdown }
122
+ : { discussion_id, rich_text: plainTextRich(text) };
123
+ const response = await notion.comments.create(asSdk(body));
124
+ return { ok: true, data: slimComment(response, verbose ?? false) };
125
+ }),
126
+ });
127
+ // ──────────────────────────────────────────────────────────────────────────
128
+ // get_comment
129
+ // ──────────────────────────────────────────────────────────────────────────
130
+ const GetCommentParams = z.object({
131
+ comment_id: z.string(),
132
+ verbose: VERBOSE,
133
+ });
134
+ register({
135
+ name: "get_comment",
136
+ description: "Retrieve a single comment by ID.",
137
+ batchable: true,
138
+ schema: GetCommentParams,
139
+ example: { comment_id: "<comment-id>" },
140
+ handler: tryHandler(async ({ comment_id, verbose }) => {
141
+ const notion = await getClient();
142
+ const response = await notion.comments.retrieve({ comment_id });
143
+ return { ok: true, data: slimComment(response, verbose ?? false) };
144
+ }),
145
+ });
146
+ // ──────────────────────────────────────────────────────────────────────────
147
+ // update_comment
148
+ // ──────────────────────────────────────────────────────────────────────────
149
+ const UpdateCommentParams = z
150
+ .object({
151
+ comment_id: z.string(),
152
+ rich_text: z.array(z.unknown()).optional(),
153
+ markdown: z.string().optional(),
154
+ verbose: VERBOSE,
155
+ })
156
+ .refine((v) => Boolean(v.rich_text) !== Boolean(v.markdown), {
157
+ message: "Pass exactly one of `rich_text` or `markdown`.",
158
+ });
159
+ register({
160
+ name: "update_comment",
161
+ description: "Replace a comment's body. Pass markdown or rich_text (not both).",
162
+ batchable: true,
163
+ schema: UpdateCommentParams,
164
+ example: { comment_id: "<comment-id>", markdown: "Updated body" },
165
+ handler: tryHandler(async ({ comment_id, rich_text, markdown, verbose }) => {
166
+ const notion = await getClient();
167
+ const body = markdown !== undefined
168
+ ? { comment_id, markdown }
169
+ : { comment_id, rich_text };
170
+ const response = await notion.comments.update(asSdk(body));
171
+ return { ok: true, data: slimComment(response, verbose ?? false) };
172
+ }),
173
+ });
174
+ // ──────────────────────────────────────────────────────────────────────────
175
+ // delete_comment
176
+ // ──────────────────────────────────────────────────────────────────────────
177
+ const DeleteCommentParams = z.object({
178
+ comment_id: z.string(),
179
+ });
180
+ register({
181
+ name: "delete_comment",
182
+ description: "Delete a comment.",
183
+ batchable: true,
184
+ schema: DeleteCommentParams,
185
+ example: { comment_id: "<comment-id>" },
186
+ handler: tryHandler(async ({ comment_id }) => {
187
+ const notion = await getClient();
188
+ await notion.comments.delete({ comment_id });
189
+ return { ok: true, data: { deleted: comment_id } };
190
+ }),
191
+ });
@@ -0,0 +1,85 @@
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 { slimDataSource } from "../utils/slim.js";
7
+ import { DATABASE_PROPERTY_SCHEMA } from "../schema/database.js";
8
+ import { asSdk } from "../utils/notion-types.js";
9
+ const VERBOSE = z.boolean().optional();
10
+ const ListDataSourcesParams = z.object({
11
+ database_id: z.string().describe("Database ID to list data sources for."),
12
+ verbose: VERBOSE,
13
+ });
14
+ register({
15
+ name: "list_data_sources",
16
+ description: "List data sources under a database. Use this before query_database when targeting multi-source databases.",
17
+ batchable: false,
18
+ schema: ListDataSourcesParams,
19
+ example: { database_id: "<database-id>" },
20
+ handler: tryHandler(async ({ database_id, verbose }) => {
21
+ const notion = await getClient();
22
+ const db = await notion.databases.retrieve({ database_id });
23
+ const sources = isFullDatabase(db) ? db.data_sources : [];
24
+ return {
25
+ ok: true,
26
+ data: verbose
27
+ ? { database_id, data_sources: sources }
28
+ : {
29
+ database_id,
30
+ data_sources: sources.map((s) => ({ id: s.id, name: s.name })),
31
+ },
32
+ };
33
+ }),
34
+ });
35
+ const GetDataSourceParams = z.object({
36
+ data_source_id: z.string(),
37
+ verbose: VERBOSE,
38
+ });
39
+ register({
40
+ name: "get_data_source",
41
+ description: "Retrieve a single data source's schema (its property definitions and parent database).",
42
+ batchable: true,
43
+ schema: GetDataSourceParams,
44
+ example: { data_source_id: "<data-source-id>" },
45
+ exampleBatch: { items: [{ data_source_id: "<ds-1>" }, { data_source_id: "<ds-2>" }] },
46
+ handler: tryHandler(async ({ data_source_id, verbose }) => {
47
+ const notion = await getClient();
48
+ const ds = await notion.dataSources.retrieve({ data_source_id });
49
+ return { ok: true, data: slimDataSource(ds, verbose ?? false) };
50
+ }),
51
+ });
52
+ const UpdateDataSourceParams = z.object({
53
+ data_source_id: z.string(),
54
+ title: z.array(z.unknown()).optional().describe("Rich text array for the data source title."),
55
+ properties: z.record(z.string(), DATABASE_PROPERTY_SCHEMA).optional(),
56
+ icon: z.unknown().optional(),
57
+ archived: z.boolean().optional(),
58
+ in_trash: z.boolean().optional(),
59
+ verbose: VERBOSE,
60
+ });
61
+ register({
62
+ name: "update_data_source",
63
+ description: "Update a data source's schema (properties, title, icon). For database-level metadata use update_database.",
64
+ batchable: true,
65
+ schema: UpdateDataSourceParams,
66
+ example: {
67
+ data_source_id: "<data-source-id>",
68
+ properties: {
69
+ Status: { type: "status", status: { options: [] } },
70
+ },
71
+ },
72
+ handler: tryHandler(async ({ data_source_id, title, properties, icon, archived, in_trash, verbose }) => {
73
+ const notion = await getClient();
74
+ const body = {
75
+ data_source_id,
76
+ ...(title !== undefined ? { title } : {}),
77
+ ...(properties !== undefined ? { properties } : {}),
78
+ ...(icon !== undefined ? { icon } : {}),
79
+ ...(archived !== undefined ? { archived } : {}),
80
+ ...(in_trash !== undefined ? { in_trash } : {}),
81
+ };
82
+ const response = await notion.dataSources.update(asSdk(body));
83
+ return { ok: true, data: slimDataSource(response, verbose ?? false) };
84
+ }),
85
+ });