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.
- package/README.md +383 -192
- package/build/config/index.js +3 -1
- package/build/dispatch/concurrency.js +15 -0
- package/build/dispatch/idempotency.js +38 -0
- package/build/dispatch/index.js +175 -0
- package/build/dispatch/rate-limit.js +56 -0
- package/build/dispatch/retry.js +97 -0
- package/build/index.js +1 -1
- package/build/markdown/parse.js +265 -0
- package/build/operations/blocks.js +331 -0
- package/build/operations/comments.js +191 -0
- package/build/operations/data-sources.js +85 -0
- package/build/operations/databases.js +345 -0
- package/build/operations/files.js +239 -0
- package/build/operations/index.js +19 -0
- package/build/operations/pages.js +486 -0
- package/build/operations/registry.js +16 -0
- package/build/operations/users.js +101 -0
- package/build/prompts/index.js +105 -0
- package/build/schema/blocks.js +19 -138
- package/build/schema/database.js +27 -111
- package/build/schema/emit.js +68 -0
- package/build/schema/file.js +1 -1
- package/build/schema/filter-dsl.js +333 -0
- package/build/schema/icon.js +1 -1
- package/build/schema/page-properties.js +17 -3
- package/build/schema/page.js +12 -125
- package/build/schema/refs.js +16 -0
- package/build/schema/rich-text.js +1 -1
- package/build/server/index.js +16 -3
- package/build/services/auth.js +19 -0
- package/build/services/notion.js +14 -17
- package/build/tools/index.js +119 -21
- package/build/utils/error.js +125 -86
- package/build/utils/handler.js +11 -0
- package/build/utils/learning-error.js +40 -0
- package/build/utils/notion-types.js +16 -0
- package/build/utils/paginate.js +35 -0
- package/build/utils/schema-slice.js +156 -0
- package/build/utils/slim.js +269 -0
- package/package.json +13 -7
- package/build/resources/imageList.js +0 -62
- package/build/resources/index.js +0 -1
- package/build/resources/predictionList.js +0 -43
- package/build/resources/svgList.js +0 -69
- package/build/schema/comments.js +0 -60
- package/build/schema/notion.js +0 -57
- package/build/schema/richText.js +0 -757
- package/build/schema/tools.js +0 -17
- package/build/schema/users.js +0 -39
- package/build/services/loggs.js +0 -13
- package/build/services/replicate.js +0 -23
- package/build/tools/appendBlockChildren.js +0 -25
- package/build/tools/batchAppendBlockChildren.js +0 -33
- package/build/tools/batchDeleteBlocks.js +0 -32
- package/build/tools/batchMixedOperations.js +0 -58
- package/build/tools/batchUpdateBlocks.js +0 -33
- package/build/tools/blocks.js +0 -34
- package/build/tools/comments.js +0 -81
- package/build/tools/createDatabase.js +0 -18
- package/build/tools/createPage.js +0 -18
- package/build/tools/createPrediction.js +0 -28
- package/build/tools/database.js +0 -16
- package/build/tools/deleteBlock.js +0 -24
- package/build/tools/formatRichText.js +0 -83
- package/build/tools/generateImage.js +0 -48
- package/build/tools/generateImageVariants.js +0 -105
- package/build/tools/generateMultipleImages.js +0 -60
- package/build/tools/generateSVG.js +0 -43
- package/build/tools/getPrediction.js +0 -22
- package/build/tools/pages.js +0 -22
- package/build/tools/predictionList.js +0 -30
- package/build/tools/queryDatabase.js +0 -22
- package/build/tools/retrieveBlock.js +0 -24
- package/build/tools/retrieveBlockChildren.js +0 -32
- package/build/tools/searchPage.js +0 -24
- package/build/tools/updateBlock.js +0 -25
- package/build/tools/updateDatabase.js +0 -18
- package/build/tools/updatePage.js +0 -40
- package/build/tools/updatePageProperties.js +0 -21
- package/build/tools/users.js +0 -75
- package/build/types/blocks.js +0 -12
- package/build/types/comments.js +0 -7
- package/build/types/database.js +0 -6
- package/build/types/notion.js +0 -1
- package/build/types/page.js +0 -8
- package/build/types/richText.js +0 -1
- package/build/types/tools.js +0 -1
- package/build/types/users.js +0 -6
- package/build/utils/blob.js +0 -5
- package/build/utils/image.js +0 -34
- package/build/utils/index.js +0 -1
- package/build/utils/richText.js +0 -174
- package/build/validation/blocks.js +0 -568
- package/build/validation/notion.js +0 -51
- package/build/validation/page.js +0 -262
- package/build/validation/richText.js +0 -744
- package/build/validation/tools.js +0 -16
- /package/build/{types/index.js → operations/types.js} +0 -0
|
@@ -0,0 +1,345 @@
|
|
|
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 { slimDatabase, slimItem, slimList } from "../utils/slim.js";
|
|
7
|
+
import { DATABASE_PROPERTY_SCHEMA } from "../schema/database.js";
|
|
8
|
+
import { PARENT_SCHEMA } from "../schema/page.js";
|
|
9
|
+
import { ICON_SCHEMA } from "../schema/icon.js";
|
|
10
|
+
import { FILE_SCHEMA } from "../schema/file.js";
|
|
11
|
+
import { TEXT_RICH_TEXT_ITEM_REQUEST_SCHEMA } from "../schema/rich-text.js";
|
|
12
|
+
import { WHERE_SCHEMA, compileWhere } from "../schema/filter-dsl.js";
|
|
13
|
+
import { asSdk, } from "../utils/notion-types.js";
|
|
14
|
+
const VERBOSE = z.boolean().optional();
|
|
15
|
+
// Notion's `dataSources.query` accepts page_size up to 100. For
|
|
16
|
+
// query_database, `page_limit` is the cap in ITEMS (rows), distinct from the
|
|
17
|
+
// `paginate.ts` helper's `limit` which counts PAGES — query rows are the
|
|
18
|
+
// natural unit because users care about row counts when they ask for
|
|
19
|
+
// "everything matching this filter".
|
|
20
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
21
|
+
const MAX_PAGE_SIZE = 100;
|
|
22
|
+
const DEFAULT_ITEM_LIMIT = 1000;
|
|
23
|
+
const MAX_ITEM_LIMIT = 1000;
|
|
24
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// create_database
|
|
26
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
27
|
+
const CreateDatabaseParams = z.object({
|
|
28
|
+
parent: PARENT_SCHEMA.optional(),
|
|
29
|
+
title: z.string().optional().describe("Plain-text title shortcut."),
|
|
30
|
+
title_rich: z.array(TEXT_RICH_TEXT_ITEM_REQUEST_SCHEMA).optional().describe("Rich-text title; overrides `title`."),
|
|
31
|
+
description: z.array(TEXT_RICH_TEXT_ITEM_REQUEST_SCHEMA).optional(),
|
|
32
|
+
properties: z.record(z.string(), DATABASE_PROPERTY_SCHEMA),
|
|
33
|
+
is_inline: z.boolean().optional(),
|
|
34
|
+
icon: ICON_SCHEMA.nullable().optional(),
|
|
35
|
+
cover: FILE_SCHEMA.nullable().optional(),
|
|
36
|
+
verbose: VERBOSE,
|
|
37
|
+
});
|
|
38
|
+
function resolveParent(parent) {
|
|
39
|
+
if (parent)
|
|
40
|
+
return parent;
|
|
41
|
+
const envId = process.env.NOTION_PAGE_ID;
|
|
42
|
+
if (envId)
|
|
43
|
+
return { type: "page_id", page_id: envId };
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
register({
|
|
47
|
+
name: "create_database",
|
|
48
|
+
description: "Create a new database. Properties is a map of name → property-type definition.",
|
|
49
|
+
batchable: true,
|
|
50
|
+
schema: CreateDatabaseParams,
|
|
51
|
+
example: {
|
|
52
|
+
title: "Tasks",
|
|
53
|
+
properties: {
|
|
54
|
+
Name: { type: "title", title: {} },
|
|
55
|
+
Status: {
|
|
56
|
+
type: "select",
|
|
57
|
+
select: {
|
|
58
|
+
options: [
|
|
59
|
+
{ name: "Open", color: "blue" },
|
|
60
|
+
{ name: "Done", color: "green" },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
rollback: async (data) => {
|
|
67
|
+
if (typeof data !== "object" || data === null)
|
|
68
|
+
return;
|
|
69
|
+
const id = data.id;
|
|
70
|
+
if (!id)
|
|
71
|
+
return;
|
|
72
|
+
const notion = await getClient();
|
|
73
|
+
await notion.databases.update(asSdk({ database_id: id, in_trash: true }));
|
|
74
|
+
},
|
|
75
|
+
handler: tryHandler(async (params) => {
|
|
76
|
+
const parent = resolveParent(params.parent);
|
|
77
|
+
if (!parent) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: {
|
|
81
|
+
code: "missing_parent",
|
|
82
|
+
message: "No parent specified and NOTION_PAGE_ID is not set.",
|
|
83
|
+
fix: "Pass `parent: {type:'page_id', page_id:'...'}` or set NOTION_PAGE_ID in the environment.",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const titleRich = params.title_rich
|
|
88
|
+
? params.title_rich
|
|
89
|
+
: params.title
|
|
90
|
+
? [{ type: "text", text: { content: params.title } }]
|
|
91
|
+
: [];
|
|
92
|
+
const notion = await getClient();
|
|
93
|
+
const body = {
|
|
94
|
+
parent,
|
|
95
|
+
title: titleRich,
|
|
96
|
+
...(params.description ? { description: params.description } : {}),
|
|
97
|
+
initial_data_source: { properties: params.properties },
|
|
98
|
+
is_inline: params.is_inline ?? false,
|
|
99
|
+
...(params.icon !== undefined ? { icon: params.icon } : {}),
|
|
100
|
+
...(params.cover !== undefined ? { cover: params.cover } : {}),
|
|
101
|
+
};
|
|
102
|
+
const response = await notion.databases.create(asSdk(body));
|
|
103
|
+
return { ok: true, data: slimDatabase(response, params.verbose ?? false) };
|
|
104
|
+
}),
|
|
105
|
+
});
|
|
106
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// query_database
|
|
108
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
109
|
+
const QueryDatabaseParams = z
|
|
110
|
+
.object({
|
|
111
|
+
database_id: z
|
|
112
|
+
.string()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Database ID. If the database has exactly one data source, we resolve it automatically. For multi-source databases, pass data_source_id instead."),
|
|
115
|
+
data_source_id: z
|
|
116
|
+
.string()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe("Data source ID. Use for multi-source databases or when you've already resolved the source via list_data_sources."),
|
|
119
|
+
where: WHERE_SCHEMA.optional().describe("Typed shorthand filter DSL. Property names map to scalar values (equals) or operator objects like {gte:3, contains:'x'}. Top-level AND/OR arrays and NOT compose. Mutually exclusive with `filter`."),
|
|
120
|
+
filter: z.unknown().optional().describe("Raw Notion filter JSON. Use this for edge cases the `where` DSL can't express. Mutually exclusive with `where`."),
|
|
121
|
+
sorts: z.array(z.unknown()).optional(),
|
|
122
|
+
start_cursor: z.string().optional(),
|
|
123
|
+
page_size: z.number().min(1).max(MAX_PAGE_SIZE).optional(),
|
|
124
|
+
paginate: z.boolean().optional().describe("Walk all result pages, up to `page_limit` items. Returns {results, truncated, pages_walked} envelope instead of {has_more, next_cursor}."),
|
|
125
|
+
page_limit: z
|
|
126
|
+
.number()
|
|
127
|
+
.min(1)
|
|
128
|
+
.max(MAX_ITEM_LIMIT)
|
|
129
|
+
.optional()
|
|
130
|
+
.describe(`Maximum items (rows) to return when \`paginate:true\`. Defaults to ${DEFAULT_ITEM_LIMIT}.`),
|
|
131
|
+
verbose: VERBOSE,
|
|
132
|
+
})
|
|
133
|
+
.refine((v) => Boolean(v.database_id) !== Boolean(v.data_source_id), {
|
|
134
|
+
message: "Pass exactly one of `database_id` or `data_source_id`.",
|
|
135
|
+
})
|
|
136
|
+
.refine((v) => !(v.where !== undefined && v.filter !== undefined), {
|
|
137
|
+
message: "Pass either `where` (typed DSL) or `filter` (raw Notion JSON), not both.",
|
|
138
|
+
});
|
|
139
|
+
register({
|
|
140
|
+
name: "query_database",
|
|
141
|
+
description: "Query a database with optional filter and sorts. Results are page objects.",
|
|
142
|
+
batchable: false,
|
|
143
|
+
schema: QueryDatabaseParams,
|
|
144
|
+
example: {
|
|
145
|
+
database_id: "<database-id>",
|
|
146
|
+
filter: { property: "Status", status: { equals: "Done" } },
|
|
147
|
+
page_size: 50,
|
|
148
|
+
},
|
|
149
|
+
handler: tryHandler(async ({ database_id, data_source_id, where, filter, sorts, start_cursor, page_size, paginate, page_limit, verbose, }) => {
|
|
150
|
+
const notion = await getClient();
|
|
151
|
+
let dsId = data_source_id;
|
|
152
|
+
if (!dsId) {
|
|
153
|
+
const db = await notion.databases.retrieve({ database_id: database_id });
|
|
154
|
+
const sources = isFullDatabase(db) ? db.data_sources : [];
|
|
155
|
+
if (sources.length === 0) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
error: {
|
|
159
|
+
code: "no_data_source",
|
|
160
|
+
message: `Database ${database_id} has no data sources.`,
|
|
161
|
+
fix: "Pass data_source_id directly, or check the database in Notion.",
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (sources.length > 1) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: {
|
|
169
|
+
code: "multi_source_database",
|
|
170
|
+
message: `Database ${database_id} has ${sources.length} data sources. Pass data_source_id explicitly.`,
|
|
171
|
+
fix: `Call list_data_sources first, then pass data_source_id. Available IDs: ${sources.map((s) => s.id).join(", ")}.`,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
dsId = sources[0].id;
|
|
176
|
+
}
|
|
177
|
+
let compiledFilter;
|
|
178
|
+
if (where !== undefined) {
|
|
179
|
+
try {
|
|
180
|
+
compiledFilter = compileWhere(where);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: {
|
|
186
|
+
code: "where_compile_error",
|
|
187
|
+
message: err instanceof Error ? err.message : String(err),
|
|
188
|
+
fix: "Check your `where` clause shape. Pass `__type` on the property to force a property type, or fall back to raw `filter`.",
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if (filter !== undefined) {
|
|
194
|
+
compiledFilter = filter;
|
|
195
|
+
}
|
|
196
|
+
const baseBody = {
|
|
197
|
+
data_source_id: dsId,
|
|
198
|
+
...(compiledFilter !== undefined ? { filter: compiledFilter } : {}),
|
|
199
|
+
...(sorts !== undefined ? { sorts } : {}),
|
|
200
|
+
};
|
|
201
|
+
const pageSize = page_size ?? DEFAULT_PAGE_SIZE;
|
|
202
|
+
const runQuery = (cursor, size) => notion.dataSources.query(asSdk({
|
|
203
|
+
...baseBody,
|
|
204
|
+
...(cursor !== undefined ? { start_cursor: cursor } : {}),
|
|
205
|
+
page_size: size,
|
|
206
|
+
}));
|
|
207
|
+
// query_database results are data source rows (pages). The SDK response
|
|
208
|
+
// type also admits data source objects themselves, so dispatch via slimItem
|
|
209
|
+
// — only the page branch consumes includeProperties=true, which is the
|
|
210
|
+
// common case and keeps callers off verbose=true (10x larger).
|
|
211
|
+
const slimRow = (item, v) => slimItem(item, v ?? false, true);
|
|
212
|
+
if (paginate) {
|
|
213
|
+
const limit = page_limit ?? DEFAULT_ITEM_LIMIT;
|
|
214
|
+
const collected = [];
|
|
215
|
+
let cursor = start_cursor;
|
|
216
|
+
let pagesWalked = 0;
|
|
217
|
+
let hasMore = false;
|
|
218
|
+
while (collected.length < limit) {
|
|
219
|
+
const remaining = limit - collected.length;
|
|
220
|
+
const response = await runQuery(cursor, Math.min(pageSize, remaining));
|
|
221
|
+
pagesWalked += 1;
|
|
222
|
+
const slim = slimList(response, slimRow, verbose ?? false);
|
|
223
|
+
for (const item of slim.results) {
|
|
224
|
+
if (collected.length >= limit)
|
|
225
|
+
break;
|
|
226
|
+
collected.push(item);
|
|
227
|
+
}
|
|
228
|
+
hasMore = Boolean(slim.has_more && slim.next_cursor);
|
|
229
|
+
if (!hasMore || collected.length >= limit)
|
|
230
|
+
break;
|
|
231
|
+
cursor = slim.next_cursor ?? undefined;
|
|
232
|
+
}
|
|
233
|
+
if (verbose) {
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
data: {
|
|
237
|
+
results: collected,
|
|
238
|
+
truncated: hasMore && collected.length >= limit,
|
|
239
|
+
pages_walked: pagesWalked,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const { parent, rows } = hoistParent(collected);
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
data: {
|
|
247
|
+
...(parent !== undefined ? { parent } : {}),
|
|
248
|
+
results: rows,
|
|
249
|
+
truncated: hasMore && collected.length >= limit,
|
|
250
|
+
pages_walked: pagesWalked,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const response = await runQuery(start_cursor, pageSize);
|
|
255
|
+
const slim = slimList(response, slimRow, verbose ?? false);
|
|
256
|
+
if (verbose)
|
|
257
|
+
return { ok: true, data: slim };
|
|
258
|
+
const { parent, rows } = hoistParent(slim.results);
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
data: {
|
|
262
|
+
...(parent !== undefined ? { parent } : {}),
|
|
263
|
+
results: rows,
|
|
264
|
+
has_more: slim.has_more,
|
|
265
|
+
next_cursor: slim.next_cursor,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}),
|
|
269
|
+
});
|
|
270
|
+
// query_database rows always come from one data source, so the `parent`
|
|
271
|
+
// object is identical across every result. Lift it to the list level — on
|
|
272
|
+
// a 100-row page that saves ~80 bytes per row, ≈8KB per response.
|
|
273
|
+
function hoistParent(rows) {
|
|
274
|
+
if (rows.length === 0)
|
|
275
|
+
return { rows: [] };
|
|
276
|
+
const first = rows[0];
|
|
277
|
+
if (first.parent === undefined)
|
|
278
|
+
return { rows: rows.slice() };
|
|
279
|
+
const parent = first.parent;
|
|
280
|
+
return {
|
|
281
|
+
parent,
|
|
282
|
+
rows: rows.map(({ parent: _omit, ...rest }) => rest),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
286
|
+
// update_database
|
|
287
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
288
|
+
const UpdateDatabaseParams = z.object({
|
|
289
|
+
database_id: z.string(),
|
|
290
|
+
title: z.string().optional(),
|
|
291
|
+
title_rich: z.array(TEXT_RICH_TEXT_ITEM_REQUEST_SCHEMA).optional(),
|
|
292
|
+
description: z.array(TEXT_RICH_TEXT_ITEM_REQUEST_SCHEMA).optional(),
|
|
293
|
+
properties: z
|
|
294
|
+
.record(z.string(), DATABASE_PROPERTY_SCHEMA)
|
|
295
|
+
.optional()
|
|
296
|
+
.describe("Deprecated on the 2025-09-03 surface — properties live on the data source. Call update_data_source instead. Rejected here so the migration is explicit."),
|
|
297
|
+
is_inline: z.boolean().optional(),
|
|
298
|
+
is_locked: z.boolean().optional(),
|
|
299
|
+
in_trash: z.boolean().optional(),
|
|
300
|
+
archived: z.boolean().optional().describe("Deprecated alias for `in_trash`. Use `in_trash` on the 2025-09-03 surface."),
|
|
301
|
+
icon: ICON_SCHEMA.nullable().optional(),
|
|
302
|
+
cover: FILE_SCHEMA.nullable().optional(),
|
|
303
|
+
verbose: VERBOSE,
|
|
304
|
+
});
|
|
305
|
+
register({
|
|
306
|
+
name: "update_database",
|
|
307
|
+
description: "Update database-level metadata (title, description, icon, cover, is_inline, is_locked, in_trash). For schema/property changes, use update_data_source.",
|
|
308
|
+
batchable: true,
|
|
309
|
+
schema: UpdateDatabaseParams,
|
|
310
|
+
example: {
|
|
311
|
+
database_id: "<database-id>",
|
|
312
|
+
title: "Renamed",
|
|
313
|
+
},
|
|
314
|
+
handler: tryHandler(async (params) => {
|
|
315
|
+
if (params.properties) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
error: {
|
|
319
|
+
code: "properties_moved",
|
|
320
|
+
message: "Property definitions are no longer accepted on update_database in the 2025-09-03 surface.",
|
|
321
|
+
fix: "Call list_data_sources to resolve the data_source_id, then update_data_source with the same properties map.",
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
const titleRich = params.title_rich
|
|
326
|
+
? params.title_rich
|
|
327
|
+
: params.title !== undefined
|
|
328
|
+
? [{ type: "text", text: { content: params.title } }]
|
|
329
|
+
: undefined;
|
|
330
|
+
const inTrash = params.in_trash ?? params.archived;
|
|
331
|
+
const notion = await getClient();
|
|
332
|
+
const body = {
|
|
333
|
+
database_id: params.database_id,
|
|
334
|
+
...(titleRich ? { title: titleRich } : {}),
|
|
335
|
+
...(params.description ? { description: params.description } : {}),
|
|
336
|
+
...(params.is_inline !== undefined ? { is_inline: params.is_inline } : {}),
|
|
337
|
+
...(params.is_locked !== undefined ? { is_locked: params.is_locked } : {}),
|
|
338
|
+
...(inTrash !== undefined ? { in_trash: inTrash } : {}),
|
|
339
|
+
...(params.icon !== undefined ? { icon: params.icon } : {}),
|
|
340
|
+
...(params.cover !== undefined ? { cover: params.cover } : {}),
|
|
341
|
+
};
|
|
342
|
+
const response = await notion.databases.update(asSdk(body));
|
|
343
|
+
return { ok: true, data: slimDatabase(response, params.verbose ?? false) };
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
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 { slimFileUpload, slimList } from "../utils/slim.js";
|
|
6
|
+
// Notion's documented per-part ceiling for multi-part uploads.
|
|
7
|
+
const MAX_PART_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
const FILE_UPLOAD_STATUS = ["pending", "uploaded", "expired", "failed"];
|
|
9
|
+
const VERBOSE = z.boolean().optional();
|
|
10
|
+
const SourceSchema = z.discriminatedUnion("type", [
|
|
11
|
+
z.object({
|
|
12
|
+
type: z.literal("base64"),
|
|
13
|
+
data: z.string().describe("Base64-encoded file bytes."),
|
|
14
|
+
}),
|
|
15
|
+
z.object({
|
|
16
|
+
type: z.literal("url"),
|
|
17
|
+
url: z.url().describe("Public URL to fetch the file bytes from."),
|
|
18
|
+
}),
|
|
19
|
+
]);
|
|
20
|
+
async function resolveBytes(source) {
|
|
21
|
+
if (source.type === "base64")
|
|
22
|
+
return Buffer.from(source.data, "base64");
|
|
23
|
+
const res = await fetch(source.url);
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
throw new Error(`Failed to fetch ${source.url}: ${res.status} ${res.statusText}`);
|
|
26
|
+
}
|
|
27
|
+
return Buffer.from(await res.arrayBuffer());
|
|
28
|
+
}
|
|
29
|
+
function splitIntoParts(buf, partSize = MAX_PART_BYTES) {
|
|
30
|
+
const parts = [];
|
|
31
|
+
for (let offset = 0; offset < buf.length; offset += partSize) {
|
|
32
|
+
parts.push(buf.subarray(offset, Math.min(offset + partSize, buf.length)));
|
|
33
|
+
}
|
|
34
|
+
return parts;
|
|
35
|
+
}
|
|
36
|
+
// Notion's File Upload API requires the Blob's type on send() to match
|
|
37
|
+
// the content_type stored at create(). It does NOT accept
|
|
38
|
+
// application/octet-stream as a fallback. The allowlist below mirrors the
|
|
39
|
+
// MIME types documented at
|
|
40
|
+
// https://developers.notion.com/docs/working-with-files-and-media — when the
|
|
41
|
+
// caller doesn't pass content_type, infer it from the filename extension so
|
|
42
|
+
// create + send agree.
|
|
43
|
+
const EXTENSION_TO_MIME = {
|
|
44
|
+
// Audio
|
|
45
|
+
aac: "audio/aac",
|
|
46
|
+
flac: "audio/x-flac",
|
|
47
|
+
m4a: "audio/mp4",
|
|
48
|
+
mid: "audio/midi",
|
|
49
|
+
midi: "audio/midi",
|
|
50
|
+
mp3: "audio/mpeg",
|
|
51
|
+
oga: "audio/ogg",
|
|
52
|
+
ogg: "audio/ogg",
|
|
53
|
+
wav: "audio/wav",
|
|
54
|
+
weba: "audio/webm",
|
|
55
|
+
wma: "audio/x-ms-wma",
|
|
56
|
+
// Image
|
|
57
|
+
apng: "image/apng",
|
|
58
|
+
avif: "image/avif",
|
|
59
|
+
bmp: "image/bmp",
|
|
60
|
+
gif: "image/gif",
|
|
61
|
+
heic: "image/heic",
|
|
62
|
+
ico: "image/vnd.microsoft.icon",
|
|
63
|
+
jpeg: "image/jpeg",
|
|
64
|
+
jpg: "image/jpeg",
|
|
65
|
+
png: "image/png",
|
|
66
|
+
svg: "image/svg+xml",
|
|
67
|
+
tif: "image/tiff",
|
|
68
|
+
tiff: "image/tiff",
|
|
69
|
+
webp: "image/webp",
|
|
70
|
+
// Video
|
|
71
|
+
"3gp": "video/3gpp",
|
|
72
|
+
"3g2": "video/3gpp2",
|
|
73
|
+
amv: "video/x-amv",
|
|
74
|
+
asf: "video/x-ms-asf",
|
|
75
|
+
avi: "video/x-msvideo",
|
|
76
|
+
f4v: "video/x-f4v",
|
|
77
|
+
flv: "video/x-flv",
|
|
78
|
+
m4v: "video/mp4",
|
|
79
|
+
mkv: "video/x-matroska",
|
|
80
|
+
mov: "video/quicktime",
|
|
81
|
+
mp4: "video/mp4",
|
|
82
|
+
mpeg: "video/mpeg",
|
|
83
|
+
mpg: "video/mpeg",
|
|
84
|
+
ogv: "video/ogg",
|
|
85
|
+
qt: "video/quicktime",
|
|
86
|
+
webm: "video/webm",
|
|
87
|
+
// Documents
|
|
88
|
+
csv: "text/csv",
|
|
89
|
+
json: "application/json",
|
|
90
|
+
pdf: "application/pdf",
|
|
91
|
+
txt: "text/plain",
|
|
92
|
+
};
|
|
93
|
+
function inferContentType(filename) {
|
|
94
|
+
const dot = filename.lastIndexOf(".");
|
|
95
|
+
if (dot < 0 || dot === filename.length - 1)
|
|
96
|
+
return undefined;
|
|
97
|
+
const ext = filename.slice(dot + 1).toLowerCase();
|
|
98
|
+
return EXTENSION_TO_MIME[ext];
|
|
99
|
+
}
|
|
100
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
101
|
+
// upload_file
|
|
102
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
103
|
+
const UploadFileParams = z.object({
|
|
104
|
+
mode: z
|
|
105
|
+
.enum(["single", "multi"])
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("'single' (default) = one create+send call. 'multi' = chunk into 5MB parts then complete."),
|
|
108
|
+
filename: z.string(),
|
|
109
|
+
content_type: z.string().optional(),
|
|
110
|
+
source: SourceSchema,
|
|
111
|
+
});
|
|
112
|
+
register({
|
|
113
|
+
name: "upload_file",
|
|
114
|
+
description: "Upload a file via Notion's file_uploads API. Handles single-part (one create + one send) and multi-part (create + N sends + complete) transparently.\n\nSource shapes:\n • Base64 bytes: `source: { type: \"base64\", data: \"<b64 string>\" }`\n • Public URL: `source: { type: \"url\", url: \"https://example.com/file.pdf\" }` (the server fetches it server-side).\n\n`mode` defaults to \"single\"; only pass \"multi\" for files larger than ~5MB.",
|
|
115
|
+
batchable: false,
|
|
116
|
+
schema: UploadFileParams,
|
|
117
|
+
example: {
|
|
118
|
+
filename: "report.pdf",
|
|
119
|
+
content_type: "application/pdf",
|
|
120
|
+
source: { type: "base64", data: "JVBERi0xLjQK..." },
|
|
121
|
+
},
|
|
122
|
+
handler: tryHandler(async ({ mode, filename, content_type, source }) => {
|
|
123
|
+
const effectiveMode = mode ?? "single";
|
|
124
|
+
const notion = await getClient();
|
|
125
|
+
const bytes = await resolveBytes(source);
|
|
126
|
+
// Notion rejects send() when the Blob's MIME doesn't match the
|
|
127
|
+
// content_type stored at create(), and rejects application/octet-stream
|
|
128
|
+
// outright. Resolve a single MIME for both sides: caller's content_type
|
|
129
|
+
// wins, else infer from the filename extension.
|
|
130
|
+
const effectiveType = content_type ?? inferContentType(filename);
|
|
131
|
+
if (!effectiveType) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: {
|
|
135
|
+
code: "validation_error",
|
|
136
|
+
message: `Could not infer content_type from filename "${filename}". Notion's File Upload API rejects application/octet-stream and only accepts a fixed allowlist of MIME types.`,
|
|
137
|
+
fix: "Pass `content_type` explicitly (e.g. \"application/pdf\", \"image/png\", \"text/plain\"). See https://developers.notion.com/docs/working-with-files-and-media for the full list.",
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (effectiveMode === "single") {
|
|
142
|
+
const createBody = {
|
|
143
|
+
mode: "single_part",
|
|
144
|
+
filename,
|
|
145
|
+
content_type: effectiveType,
|
|
146
|
+
};
|
|
147
|
+
const created = await notion.fileUploads.create(createBody);
|
|
148
|
+
const sendBody = {
|
|
149
|
+
file_upload_id: created.id,
|
|
150
|
+
file: { filename, data: new Blob([bytes], { type: effectiveType }) },
|
|
151
|
+
};
|
|
152
|
+
const sent = await notion.fileUploads.send(sendBody);
|
|
153
|
+
return { ok: true, data: slimFileUpload(sent) };
|
|
154
|
+
}
|
|
155
|
+
const parts = splitIntoParts(bytes);
|
|
156
|
+
const createBody = {
|
|
157
|
+
mode: "multi_part",
|
|
158
|
+
filename,
|
|
159
|
+
content_type: effectiveType,
|
|
160
|
+
number_of_parts: parts.length,
|
|
161
|
+
};
|
|
162
|
+
const created = await notion.fileUploads.create(createBody);
|
|
163
|
+
for (const [index, part] of parts.entries()) {
|
|
164
|
+
const partNumber = index + 1;
|
|
165
|
+
const sendBody = {
|
|
166
|
+
file_upload_id: created.id,
|
|
167
|
+
file: { filename, data: new Blob([part], { type: effectiveType }) },
|
|
168
|
+
part_number: String(partNumber),
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
await notion.fileUploads.send(sendBody);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
// Notion has no abort endpoint — the upload object expires on its
|
|
175
|
+
// own. Surface part number + upload id so the caller can either
|
|
176
|
+
// retry the upload from scratch or look up the dangling object.
|
|
177
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
178
|
+
throw new Error(`Multi-part upload ${created.id} failed on part ${partNumber}/${parts.length}: ${reason}. The upload object will expire automatically; re-call upload_file to retry.`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const completed = await notion.fileUploads.complete({
|
|
182
|
+
file_upload_id: created.id,
|
|
183
|
+
});
|
|
184
|
+
return { ok: true, data: slimFileUpload(completed) };
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
188
|
+
// list_file_uploads
|
|
189
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
190
|
+
const ListFileUploadsParams = z.object({
|
|
191
|
+
status: z.enum(FILE_UPLOAD_STATUS).optional(),
|
|
192
|
+
start_cursor: z.string().optional(),
|
|
193
|
+
page_size: z.number().min(1).max(100).optional(),
|
|
194
|
+
verbose: VERBOSE,
|
|
195
|
+
});
|
|
196
|
+
register({
|
|
197
|
+
name: "list_file_uploads",
|
|
198
|
+
description: "List file uploads, optionally filtered by status.",
|
|
199
|
+
batchable: false,
|
|
200
|
+
schema: ListFileUploadsParams,
|
|
201
|
+
example: { status: "uploaded" },
|
|
202
|
+
handler: tryHandler(async ({ status, start_cursor, page_size, verbose }) => {
|
|
203
|
+
const notion = await getClient();
|
|
204
|
+
const response = await notion.fileUploads.list({
|
|
205
|
+
...(status !== undefined ? { status } : {}),
|
|
206
|
+
...(start_cursor !== undefined ? { start_cursor } : {}),
|
|
207
|
+
...(page_size !== undefined ? { page_size } : {}),
|
|
208
|
+
});
|
|
209
|
+
return {
|
|
210
|
+
ok: true,
|
|
211
|
+
data: slimList(response, slimFileUpload, verbose ?? false),
|
|
212
|
+
};
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
216
|
+
// get_file_upload
|
|
217
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
218
|
+
const GetFileUploadParams = z.object({
|
|
219
|
+
file_upload_id: z.string(),
|
|
220
|
+
verbose: VERBOSE,
|
|
221
|
+
});
|
|
222
|
+
register({
|
|
223
|
+
name: "get_file_upload",
|
|
224
|
+
description: "Retrieve a single file upload by ID.",
|
|
225
|
+
batchable: true,
|
|
226
|
+
schema: GetFileUploadParams,
|
|
227
|
+
example: { file_upload_id: "<file-upload-id>" },
|
|
228
|
+
exampleBatch: {
|
|
229
|
+
items: [
|
|
230
|
+
{ file_upload_id: "<fu-1>" },
|
|
231
|
+
{ file_upload_id: "<fu-2>" },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
handler: tryHandler(async ({ file_upload_id, verbose }) => {
|
|
235
|
+
const notion = await getClient();
|
|
236
|
+
const response = await notion.fileUploads.retrieve({ file_upload_id });
|
|
237
|
+
return { ok: true, data: slimFileUpload(response, verbose ?? false) };
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { registerSharedSubSchemas } from "../schema/refs.js";
|
|
2
|
+
let initialized = false;
|
|
3
|
+
export async function initOperations() {
|
|
4
|
+
if (initialized)
|
|
5
|
+
return;
|
|
6
|
+
initialized = true;
|
|
7
|
+
registerSharedSubSchemas();
|
|
8
|
+
// Side-effect imports register every operation into the central registry.
|
|
9
|
+
await Promise.all([
|
|
10
|
+
import("./pages.js"),
|
|
11
|
+
import("./blocks.js"),
|
|
12
|
+
import("./databases.js"),
|
|
13
|
+
import("./data-sources.js"),
|
|
14
|
+
import("./comments.js"),
|
|
15
|
+
import("./users.js"),
|
|
16
|
+
import("./files.js"),
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
export { listOperations, getOperation, operationNames } from "./registry.js";
|