@zackbart/connecta 0.29.0 → 0.31.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +126 -134
  3. package/bin/connecta.mjs +13 -2
  4. package/bin/version.mjs +1 -1
  5. package/dist/argument-redaction.js +230 -0
  6. package/dist/auth-elicitation.js +14 -9
  7. package/dist/authorization-handoff.js +111 -0
  8. package/dist/call-recovery.js +25 -0
  9. package/dist/catalog-cache.js +2 -2
  10. package/dist/catalog-service.js +103 -63
  11. package/dist/config-schema.d.ts +14 -7
  12. package/dist/config-schema.js +29 -10
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +12 -7
  15. package/dist/connectors/guarded-fetch.d.ts +9 -1
  16. package/dist/connectors/guarded-fetch.js +17 -5
  17. package/dist/connectors/negotiation-cache.js +1 -1
  18. package/dist/downstream-input-context.js +9 -0
  19. package/dist/downstream-input.js +13 -11
  20. package/dist/errors.d.ts +38 -12
  21. package/dist/errors.js +19 -9
  22. package/dist/execute.js +375 -43
  23. package/dist/executor-result.js +11 -9
  24. package/dist/executors/quickjs-child.js +12 -5
  25. package/dist/executors/quickjs-protocol.js +5 -6
  26. package/dist/executors/quickjs-runtime.js +6 -1
  27. package/dist/executors/quickjs.js +20 -4
  28. package/dist/guest-runtime.js +52 -2
  29. package/dist/guest-types.d.ts +47 -5
  30. package/dist/index.d.ts +1 -1
  31. package/dist/invocation.js +72 -14
  32. package/dist/meta-output.js +0 -15
  33. package/dist/meta-tools.js +166 -95
  34. package/dist/oauth-handoff.js +88 -11
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +9 -0
  37. package/dist/operator-ui/view.js +11 -0
  38. package/dist/program-result.js +5 -0
  39. package/dist/program-writes.js +7 -48
  40. package/dist/provider.d.ts +8 -6
  41. package/dist/provider.js +3 -4
  42. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  43. package/dist/providers/_shared/request-token.js +83 -0
  44. package/dist/providers/_shared/rest/dispatch.js +41 -0
  45. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  46. package/dist/providers/_shared/rest/operation-index.js +527 -0
  47. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  48. package/dist/providers/_shared/rest/tools.js +569 -0
  49. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  50. package/dist/providers/_shared/rest/value-safety.js +580 -0
  51. package/dist/providers/cloudflare/graphql.js +385 -0
  52. package/dist/providers/cloudflare/index.d.ts +47 -51
  53. package/dist/providers/cloudflare/index.js +188 -1997
  54. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  55. package/dist/providers/cloudflare/rest.d.ts +37 -0
  56. package/dist/providers/cloudflare/rest.js +1056 -0
  57. package/dist/providers/cloudflare/skill.generated.js +7 -4
  58. package/dist/providers/cloudflare/value-safety.js +1247 -0
  59. package/dist/providers/infisical/index.d.ts +7 -0
  60. package/dist/providers/infisical/index.js +713 -0
  61. package/dist/providers/infisical/skill.generated.js +8 -0
  62. package/dist/providers/notion/index.d.ts +35 -31
  63. package/dist/providers/notion/index.js +60 -1499
  64. package/dist/providers/notion/openapi.generated.js +75 -0
  65. package/dist/providers/notion/rest.d.ts +24 -0
  66. package/dist/providers/notion/rest.js +1067 -0
  67. package/dist/providers/notion/skill.generated.js +5 -4
  68. package/dist/providers/notion/value-safety.js +31 -0
  69. package/dist/providers/stripe/index.d.ts +26 -20
  70. package/dist/providers/stripe/index.js +103 -130
  71. package/dist/providers/stripe/openapi.generated.js +649 -0
  72. package/dist/providers/stripe/rest.d.ts +14 -0
  73. package/dist/providers/stripe/rest.js +395 -0
  74. package/dist/providers/stripe/skill.generated.js +7 -5
  75. package/dist/providers/stripe/value-safety.js +705 -0
  76. package/dist/providers/vercel/index.d.ts +31 -22
  77. package/dist/providers/vercel/index.js +96 -853
  78. package/dist/providers/vercel/openapi.generated.js +456 -0
  79. package/dist/providers/vercel/rest.d.ts +20 -0
  80. package/dist/providers/vercel/rest.js +1002 -0
  81. package/dist/providers/vercel/skill.generated.js +4 -5
  82. package/dist/providers/vercel/value-safety.js +328 -0
  83. package/dist/registry.d.ts +8 -6
  84. package/dist/registry.js +134 -61
  85. package/dist/resource-read.js +4 -1
  86. package/dist/routes/mcp.js +11 -2
  87. package/dist/sent-secrets.d.ts +54 -13
  88. package/dist/sent-secrets.js +770 -231
  89. package/dist/skills.js +26 -16
  90. package/dist/sqlite.js +21 -6
  91. package/dist/storage/keys.js +20 -5
  92. package/dist/storage/memory.js +9 -4
  93. package/dist/storage/sql.js +18 -9
  94. package/dist/types.d.ts +17 -3
  95. package/dist/usage-guide.js +49 -40
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/dist/worker.js +2 -1
  99. package/dist/write-outcome.js +47 -0
  100. package/documentation/README.md +64 -0
  101. package/documentation/architecture.md +241 -97
  102. package/documentation/auth.md +138 -5
  103. package/documentation/code-mode.md +224 -107
  104. package/documentation/deploying.md +146 -0
  105. package/documentation/integrating.md +170 -0
  106. package/documentation/meta-tools.md +142 -30
  107. package/documentation/operating.md +135 -0
  108. package/documentation/operator-tests.md +4 -0
  109. package/documentation/operator-ui.md +132 -0
  110. package/documentation/provider-auth-migration.md +307 -0
  111. package/documentation/provider-migration-0.29.md +22 -5
  112. package/examples/worker/README.md +9 -2
  113. package/package.json +9 -2
  114. package/templates/node/README.md +9 -2
  115. package/templates/node/package.json +1 -1
  116. package/dist/providers/vercel/mcp-ownership.js +0 -212
@@ -0,0 +1,1067 @@
1
+ import { retryAfterMs } from "../../connectors/guarded-fetch.js";
2
+ import { ConnectorCallError } from "../../errors.js";
3
+ import { OperationIndex } from "../_shared/rest/operation-index.js";
4
+ import { callRest, restCall, restTools, restTransport, } from "../_shared/rest/tools.js";
5
+ import { valueSafety } from "../_shared/rest/value-safety.js";
6
+ import { openapi } from "./openapi.generated.js";
7
+ import { NOTION_VALUE_SAFETY } from "./value-safety.js";
8
+ /** Notion's REST origin. Every request this connector sends goes to exactly this host. */
9
+ export const NOTION_API_BASE_URL = "https://api.notion.com";
10
+ /**
11
+ * Pinned with no override, and equal to the pinned index's `Notion-Version`
12
+ * (a test holds them together, so regenerating the index against a newer
13
+ * version fails until this moves with it). Notion's date-named versions keep
14
+ * working indefinitely, which makes an override look harmless; it is not.
15
+ * `2026-03-11` is where databases split into data sources, `archived` became
16
+ * `in_trash`, and block append took a `position` object instead of an `after`
17
+ * string. Every projection, write body, and index contract here assumes those
18
+ * shapes, so an older version would return quietly wrong results rather than
19
+ * fail loudly.
20
+ */
21
+ export const NOTION_API_VERSION = "2026-03-11";
22
+ /** Notion's hard cap on `page_size` for every paginated endpoint. */
23
+ export const MAX_PAGE_SIZE = 100;
24
+ /** Notion's cap on `children` per append, and on blocks per page create. */
25
+ const MAX_CHILDREN_PER_REQUEST = 100;
26
+ /**
27
+ * Ceiling on the fetches one `integration_get_page_content` walk may spend. Call admission
28
+ * meters tool calls, not the requests inside them, so a deep `depth` would
29
+ * otherwise drain the budget invisibly; the walk stops here and reports
30
+ * `truncated: true` instead.
31
+ */
32
+ const MAX_CONTENT_REQUESTS = 20;
33
+ /**
34
+ * The largest response this connection will read. `page_size` tops out at
35
+ * 100 and every payload is JSON, so four mebibytes is a ceiling on absurdity,
36
+ * not a budget any real read has to think about.
37
+ */
38
+ const NOTION_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
39
+ const READ_CAP_MS = 60_000;
40
+ /**
41
+ * POSTs that only read, admitted by `notion_api_read`. Each is a query over
42
+ * existing objects that persists nothing. `POST /v1/views/{view_id}/queries`
43
+ * is deliberately absent: it creates a stored view query that lives until
44
+ * `DELETE /v1/views/{view_id}/queries/{query_id}` removes it.
45
+ */
46
+ const READ_POSTS = [
47
+ ["POST", "/v1/search", "Searches titles shared with the integration; Notion persists nothing."],
48
+ [
49
+ "POST",
50
+ "/v1/data_sources/{data_source_id}/query",
51
+ "Lists a data source's rows by filter and sort; persists nothing.",
52
+ ],
53
+ ["POST", "/v1/blocks/meeting_notes/query", "Lists meeting notes by filter and sort; persists nothing."],
54
+ ["POST", "/v1/agents/query", "Lists custom agents by name, filter, and sort; persists nothing."],
55
+ ["POST", "/v1/sessions/query", "Lists agent sessions by title, filter, and sort; persists nothing."],
56
+ ["POST", "/v1/sessions/{session_id}/events/query", "Lists one session's events; persists nothing."],
57
+ ];
58
+ function isRecord(value) {
59
+ return typeof value === "object" && value !== null && !Array.isArray(value);
60
+ }
61
+ function invalid(message) {
62
+ throw new ConnectorCallError("invalid_args", message);
63
+ }
64
+ /**
65
+ * Map to what the caller should do next, not to what Notion's `code` says
66
+ * happened. The two that are easy to mistranslate:
67
+ *
68
+ * - **403 is not an authentication failure.** The token is fine; the integration
69
+ * lacks a capability or was never shared the object, and neither
70
+ * `authorize_connector` nor a new token can fix it. Non-retryable call failure.
71
+ * - **404 does not prove absence.** Notion returns `object_not_found` both for an
72
+ * object that does not exist and for one never shared with the integration, and
73
+ * will not say which — so not `not_found`, which exists to assert absence
74
+ * (H11). The message names both possibilities.
75
+ */
76
+ function notionFailure(status, headers, payload, withheld = false) {
77
+ const body = isRecord(payload) ? payload : undefined;
78
+ const code = typeof body?.["code"] === "string" ? body["code"] : undefined;
79
+ // For an operation the value-safety review withholds, Notion's message is
80
+ // replaced; its code still routes.
81
+ const detail = withheld
82
+ ? `Notion answered HTTP ${status}; its message is withheld because this operation handles credentials.`
83
+ : typeof body?.["message"] === "string" && body["message"].trim()
84
+ ? body["message"].trim()
85
+ : `Notion returned HTTP ${status}.`;
86
+ const retryAfter = retryAfterMs(headers);
87
+ const labelled = code ? `Notion ${code}: ${detail}` : detail;
88
+ if (status === 429) {
89
+ const additional = body?.["additional_data"];
90
+ const reason = isRecord(additional) ? additional["rate_limit_reason"] : undefined;
91
+ return new ConnectorCallError("rate_limited", `${labelled}${typeof reason === "string" ? ` (limit: ${reason})` : ""} Notion allows roughly three requests per second per integration.`, { retryAfterMs: retryAfter ?? 1_000 });
92
+ }
93
+ if (status === 529) {
94
+ // Notion documents 529 alongside 429: back off and respect Retry-After.
95
+ return new ConnectorCallError("unavailable", `${labelled} Notion is overloaded; retry after the reported window.`, {
96
+ retryAfterMs: retryAfter ?? 5_000,
97
+ });
98
+ }
99
+ if (status === 401) {
100
+ return new ConnectorCallError("auth_required", `${labelled} The Notion integration token is missing or invalid. Call authorize_connector for recovery options. When available, an operator can set a valid token in this connection in the operator UI.`);
101
+ }
102
+ if (status === 403) {
103
+ return new ConnectorCallError("provider_permission_denied", `${labelled} The token is valid but this integration is not allowed to perform this operation. An operator must enable the matching capability on the Notion integration (comment capabilities are off by default) or share the object with it. Re-authorizing will not help.`, { retryable: false });
104
+ }
105
+ if (status === 404) {
106
+ return new ConnectorCallError("connector_call_failed", `${labelled} Notion returns this both for an object that does not exist and for one that exists but has not been shared with this integration — do not treat it as proof of deletion. Confirm the id, then confirm the page or database is shared with the integration in Notion.`, { retryable: false });
107
+ }
108
+ if (status === 400) {
109
+ // Every documented 400 (validation_error, invalid_json, invalid_request,
110
+ // invalid_request_url, missing_version, invalid_beta) is a malformed
111
+ // request, which is exactly what invalid_args means to the caller.
112
+ return new ConnectorCallError("invalid_args", labelled);
113
+ }
114
+ if (status === 409) {
115
+ return new ConnectorCallError("unavailable", `${labelled} Notion reported a write conflict; this is safe to retry.`, {
116
+ retryAfterMs: retryAfter ?? 1_000,
117
+ });
118
+ }
119
+ if (status >= 500) {
120
+ return new ConnectorCallError("unavailable", `${labelled} Notion is failing upstream.`, retryAfter !== undefined ? { retryAfterMs: retryAfter } : {});
121
+ }
122
+ return new ConnectorCallError("connector_call_failed", labelled, { retryable: false });
123
+ }
124
+ /**
125
+ * Notion lists carry `results` and `has_more`; the cursor, when there is one,
126
+ * is `next_cursor` and goes back as `start_cursor`: a query parameter on GET
127
+ * lists, a body field on the read-only POST queries. Two shapes differ:
128
+ *
129
+ * - `POST /v1/views/{view_id}/queries` answers a `view_query` object whose
130
+ * first page continues at `GET /v1/views/{view_id}/queries/{query_id}`
131
+ * (until `expires_at`), so the page names that path.
132
+ * - `POST /v1/blocks/meeting_notes/query` has no cursor at all: `has_more`
133
+ * past its `limit` (50 at most) can only be answered by a narrower filter.
134
+ *
135
+ * Every shape keeps `hasMore`, so a `select` that drops the body's own
136
+ * completeness fields cannot hide that a list was cut short.
137
+ */
138
+ function page(data, call) {
139
+ if (!isRecord(data) || typeof data["has_more"] !== "boolean" || !Array.isArray(data["results"]))
140
+ return undefined;
141
+ const hasMore = data["has_more"];
142
+ const next = typeof data["next_cursor"] === "string" && data["next_cursor"] !== "" ? data["next_cursor"] : undefined;
143
+ if (!hasMore || !next)
144
+ return { hasMore };
145
+ if (data["object"] === "view_query" && typeof data["id"] === "string" && typeof data["view_id"] === "string") {
146
+ return {
147
+ hasMore,
148
+ next,
149
+ param: "start_cursor",
150
+ in: "query",
151
+ path: `/v1/views/${encodeURIComponent(data["view_id"])}/queries/${encodeURIComponent(data["id"])}`,
152
+ };
153
+ }
154
+ return { hasMore, next, param: "start_cursor", ...(call.method === "GET" ? {} : { in: "body" }) };
155
+ }
156
+ /** The pinned index, shared by every Notion REST connector in the deployment. */
157
+ let index;
158
+ let transport;
159
+ function notionIndex() {
160
+ index ??= new OperationIndex(openapi, { vendor: "notion", title: "Notion" });
161
+ return index;
162
+ }
163
+ /** The reviewed value-safety table over the pinned index. */
164
+ const SAFETY = valueSafety(NOTION_VALUE_SAFETY, () => notionIndex());
165
+ function notionTransport() {
166
+ transport ??= restTransport({
167
+ provider: "Notion",
168
+ baseUrl: NOTION_API_BASE_URL,
169
+ headers: { "Notion-Version": NOTION_API_VERSION },
170
+ maxResponseBytes: NOTION_MAX_RESPONSE_BYTES,
171
+ timeoutMs: READ_CAP_MS,
172
+ authenticate: async (ctx) => {
173
+ const token = (await ctx.credential?.get())?.trim();
174
+ if (!token) {
175
+ throw new ConnectorCallError("auth_required", "No Notion integration token is configured for this connector. Call authorize_connector for recovery options. When available, an operator can add the token in this connection in the operator UI.");
176
+ }
177
+ return { Authorization: `Bearer ${token}` };
178
+ },
179
+ });
180
+ return transport;
181
+ }
182
+ const VENDOR = {
183
+ vendor: "notion",
184
+ title: "Notion",
185
+ get index() {
186
+ return notionIndex();
187
+ },
188
+ transport(server) {
189
+ return server === undefined
190
+ ? notionTransport()
191
+ : `This operation is served from ${server}, which this connector does not reach.`;
192
+ },
193
+ failure: (status, headers, payload, call) => notionFailure(status, headers, payload, call ? SAFETY.withholdsErrors(call.op) : false),
194
+ readPosts: READ_POSTS,
195
+ valueSafety: SAFETY,
196
+ page,
197
+ upload: 'Create the upload with POST /v1/file_uploads and mode "external_url" instead; this connector sends no multipart bodies.',
198
+ bodyHint: "Notion's JSON request body as notion_api_details lists it",
199
+ };
200
+ /**
201
+ * Send one named-tool request on the shared path (same transport, framing,
202
+ * failure mapping) and insist on an object back. A sent write whose answer
203
+ * cannot be read stays unknown: the caller is told not to repeat it.
204
+ */
205
+ async function notion(ctx, method, path, input = {}) {
206
+ const { data } = await callRest(VENDOR, restCall(VENDOR, method, path, input), ctx);
207
+ if (!isRecord(data)) {
208
+ throw new ConnectorCallError("connector_call_failed", "Notion returned an unreadable or non-object response for a successful status. The request was sent, but its result could not be read; do not repeat a write to recover its result.", { retryable: false });
209
+ }
210
+ return data;
211
+ }
212
+ const id = (value) => encodeURIComponent(String(value));
213
+ // Projections. Notion wraps every property in a discriminated object, every
214
+ // string in an array of annotated rich-text runs, and every user in a nested
215
+ // object, so a small query is tens of kilobytes of structure around a few
216
+ // hundred bytes of meaning. Every named read collapses that to ids, plain
217
+ // text, and flattened values. Nothing is lost for good: the reads that can
218
+ // drop something take `raw: true` and return Notion's untouched response.
219
+ //
220
+ // Unknown types unwrap rather than switch exhaustively, because Notion ships
221
+ // additive changes to every pinned version at once — a property type newer than
222
+ // this release degrades to its raw value instead of vanishing.
223
+ /** Concatenate a rich-text array to its plain text. Safe for every variant. */
224
+ function plainText(value) {
225
+ if (!Array.isArray(value))
226
+ return "";
227
+ return value.map((run) => (typeof run?.plain_text === "string" ? run.plain_text : "")).join("");
228
+ }
229
+ /** Wrap a plain string as the single-run rich-text array Notion expects. */
230
+ function richText(value) {
231
+ return [{ type: "text", text: { content: value } }];
232
+ }
233
+ function userRef(value) {
234
+ if (!value || typeof value !== "object")
235
+ return null;
236
+ return {
237
+ id: typeof value.id === "string" ? value.id : null,
238
+ ...(typeof value.name === "string" ? { name: value.name } : {}),
239
+ };
240
+ }
241
+ /**
242
+ * Flatten one Notion property value.
243
+ *
244
+ * The `default` branch is not laziness: Notion adds property types to every
245
+ * API version simultaneously, so an exhaustive switch would start returning
246
+ * `undefined` for a type that shipped after this release. Unwrapping
247
+ * `value[value.type]` degrades an unknown type to its raw payload instead.
248
+ */
249
+ function projectPropertyValue(value) {
250
+ const type = value?.type;
251
+ switch (type) {
252
+ case "title":
253
+ case "rich_text":
254
+ return plainText(value[type]);
255
+ case "number":
256
+ case "checkbox":
257
+ case "url":
258
+ case "email":
259
+ case "phone_number":
260
+ case "created_time":
261
+ case "last_edited_time":
262
+ return value[type] ?? null;
263
+ case "select":
264
+ case "status":
265
+ return value[type]?.name ?? null;
266
+ case "multi_select":
267
+ return (value.multi_select ?? []).map((option) => option?.name ?? null);
268
+ case "date":
269
+ return value.date
270
+ ? {
271
+ start: value.date.start ?? null,
272
+ end: value.date.end ?? null,
273
+ ...(value.date.time_zone ? { time_zone: value.date.time_zone } : {}),
274
+ }
275
+ : null;
276
+ case "people":
277
+ return (value.people ?? []).map(userRef);
278
+ case "created_by":
279
+ case "last_edited_by":
280
+ return userRef(value[type]);
281
+ case "files":
282
+ return (value.files ?? []).map((file) => ({
283
+ name: file?.name ?? null,
284
+ // A `file` upload carries a signed URL that expires; an `external` one
285
+ // is a plain link. Agents want the link either way.
286
+ url: file?.external?.url ?? file?.file?.url ?? null,
287
+ }));
288
+ case "relation":
289
+ return (value.relation ?? [])
290
+ .map((related) => related?.id ?? null)
291
+ .filter((related) => typeof related === "string");
292
+ case "formula":
293
+ return value.formula?.[value.formula?.type] ?? null;
294
+ case "rollup": {
295
+ const rollup = value.rollup;
296
+ if (!rollup)
297
+ return null;
298
+ if (rollup.type === "array") {
299
+ return (rollup.array ?? []).map(projectPropertyValue);
300
+ }
301
+ return rollup[rollup.type] ?? null;
302
+ }
303
+ case "unique_id":
304
+ return value.unique_id?.prefix
305
+ ? `${value.unique_id.prefix}-${value.unique_id.number}`
306
+ : (value.unique_id?.number ?? null);
307
+ case "verification":
308
+ return value.verification?.state ?? null;
309
+ default:
310
+ return type ? (value[type] ?? null) : null;
311
+ }
312
+ }
313
+ function projectProperties(source, select) {
314
+ const properties = {};
315
+ const truncated = [];
316
+ if (!source || typeof source !== "object")
317
+ return { properties, truncated };
318
+ for (const [name, value] of Object.entries(source)) {
319
+ if (select && !select.includes(name))
320
+ continue;
321
+ properties[name] = projectPropertyValue(value);
322
+ if (value?.has_more === true) {
323
+ truncated.push({ name, id: typeof value?.id === "string" ? value.id : null });
324
+ }
325
+ }
326
+ return { properties, truncated };
327
+ }
328
+ /** The title property's name is arbitrary; its `type` is not. */
329
+ function pageTitle(source) {
330
+ if (!source || typeof source !== "object")
331
+ return "";
332
+ for (const value of Object.values(source)) {
333
+ if (value?.type === "title")
334
+ return plainText(value.title);
335
+ }
336
+ return "";
337
+ }
338
+ function parentRef(parent) {
339
+ if (!parent || typeof parent !== "object")
340
+ return null;
341
+ const type = parent.type;
342
+ if (typeof type !== "string")
343
+ return null;
344
+ return { type, id: typeof parent[type] === "string" ? parent[type] : null };
345
+ }
346
+ function iconRef(icon) {
347
+ if (!icon || typeof icon !== "object")
348
+ return null;
349
+ if (typeof icon.emoji === "string")
350
+ return icon.emoji;
351
+ return icon.external?.url ?? icon.file?.url ?? null;
352
+ }
353
+ function projectPage(page, select) {
354
+ const { properties, truncated } = projectProperties(page?.properties, select);
355
+ return {
356
+ id: page?.id ?? null,
357
+ object: "page",
358
+ title: pageTitle(page?.properties),
359
+ url: page?.url ?? null,
360
+ parent: parentRef(page?.parent),
361
+ icon: iconRef(page?.icon),
362
+ created_time: page?.created_time ?? null,
363
+ last_edited_time: page?.last_edited_time ?? null,
364
+ created_by: userRef(page?.created_by),
365
+ last_edited_by: userRef(page?.last_edited_by),
366
+ in_trash: page?.in_trash === true,
367
+ is_archived: page?.is_archived === true,
368
+ properties,
369
+ ...(truncated.length ? { truncated_properties: truncated } : {}),
370
+ };
371
+ }
372
+ /**
373
+ * Identity fields only, no properties. A 25-result search across a populated
374
+ * database would otherwise drag back hundreds of flattened values for results
375
+ * the agent is about to discard; `integration_get_page` fetches them for the one that hit.
376
+ */
377
+ function projectSearchHit(hit) {
378
+ if (hit?.object === "data_source") {
379
+ return {
380
+ id: hit?.id ?? null,
381
+ object: "data_source",
382
+ title: plainText(hit?.title) || (hit?.name ?? ""),
383
+ database_id: hit?.parent?.database_id ?? null,
384
+ url: hit?.url ?? null,
385
+ last_edited_time: hit?.last_edited_time ?? null,
386
+ };
387
+ }
388
+ return {
389
+ id: hit?.id ?? null,
390
+ object: hit?.object ?? "page",
391
+ title: hit?.object === "page" ? pageTitle(hit?.properties) : plainText(hit?.title),
392
+ url: hit?.url ?? null,
393
+ parent: parentRef(hit?.parent),
394
+ last_edited_time: hit?.last_edited_time ?? null,
395
+ };
396
+ }
397
+ /** Flatten one block to its text plus the few fields its type actually adds. */
398
+ function projectBlock(block, depth) {
399
+ const type = block?.type;
400
+ const payload = type ? block?.[type] : undefined;
401
+ const projected = {
402
+ id: block?.id ?? null,
403
+ type: type ?? "unsupported",
404
+ depth,
405
+ text: plainText(payload?.rich_text),
406
+ has_children: block?.has_children === true,
407
+ };
408
+ switch (type) {
409
+ case "to_do":
410
+ projected["checked"] = payload?.checked === true;
411
+ break;
412
+ case "code":
413
+ projected["language"] = payload?.language ?? null;
414
+ break;
415
+ case "child_page":
416
+ case "child_database":
417
+ // Notion gives these a plain string title, not a rich-text array.
418
+ projected["text"] = typeof payload?.title === "string" ? payload.title : "";
419
+ break;
420
+ case "image":
421
+ case "video":
422
+ case "file":
423
+ case "pdf":
424
+ projected["url"] = payload?.external?.url ?? payload?.file?.url ?? null;
425
+ projected["text"] = plainText(payload?.caption);
426
+ break;
427
+ case "bookmark":
428
+ case "embed":
429
+ case "link_preview":
430
+ projected["url"] = payload?.url ?? null;
431
+ projected["text"] = plainText(payload?.caption);
432
+ break;
433
+ case "equation":
434
+ projected["text"] = payload?.expression ?? "";
435
+ break;
436
+ case "table_row":
437
+ projected["cells"] = (payload?.cells ?? []).map(plainText);
438
+ break;
439
+ case "callout":
440
+ projected["icon"] = iconRef(payload?.icon);
441
+ break;
442
+ default:
443
+ // A block type this projection does not model keeps its payload verbatim
444
+ // rather than collapsing to an empty string, so a block type newer than
445
+ // this release still carries its content.
446
+ if (carriesUnprojectedContent(payload))
447
+ projected["raw"] = payload;
448
+ break;
449
+ }
450
+ return projected;
451
+ }
452
+ function carriesUnprojectedContent(payload) {
453
+ if (!payload || typeof payload !== "object")
454
+ return false;
455
+ if (Array.isArray(payload.rich_text))
456
+ return false;
457
+ return Object.keys(payload).some((key) => key !== "color");
458
+ }
459
+ /**
460
+ * A data source's schema, reduced to what a caller needs to filter and write.
461
+ *
462
+ * Select and status options are kept because a filter or a write that invents
463
+ * an option name fails; everything else about a property collapses to its type.
464
+ */
465
+ function projectSchemaProperty(property) {
466
+ const type = property?.type;
467
+ const projected = { id: property?.id ?? null, type: type ?? null };
468
+ const payload = type ? property?.[type] : undefined;
469
+ if (type === "select" || type === "multi_select") {
470
+ projected["options"] = (payload?.options ?? []).map((option) => option?.name ?? null);
471
+ }
472
+ else if (type === "status") {
473
+ projected["options"] = (payload?.options ?? []).map((option) => option?.name ?? null);
474
+ projected["groups"] = (payload?.groups ?? []).map((group) => group?.name ?? null);
475
+ }
476
+ else if (type === "relation") {
477
+ // Requests must send data_source_id; responses carry both. Give the caller
478
+ // the one it is allowed to write with.
479
+ projected["relation_data_source_id"] = payload?.data_source_id ?? null;
480
+ }
481
+ else if (type === "formula") {
482
+ projected["expression"] = payload?.expression ?? null;
483
+ }
484
+ else if (type === "rollup") {
485
+ projected["rollup"] = {
486
+ relation_property_name: payload?.relation_property_name ?? null,
487
+ rollup_property_name: payload?.rollup_property_name ?? null,
488
+ function: payload?.function ?? null,
489
+ };
490
+ }
491
+ return projected;
492
+ }
493
+ // Shared schema fragments. The compact renderer inlines every property
494
+ // description, so these are paid for once per tool that uses them; the long
495
+ // versions live in the usage guide, which is fetched once rather than per tool
496
+ // ([#342](https://github.com/zackbart/connecta/issues/342)).
497
+ const RAW_PROPERTY = {
498
+ type: "boolean",
499
+ description: "Return Notion's much larger unprojected response instead of the lean projection.",
500
+ };
501
+ const PAGE_SIZE_PROPERTY = {
502
+ type: "integer",
503
+ minimum: 1,
504
+ maximum: MAX_PAGE_SIZE,
505
+ description: `Results per page (1-${MAX_PAGE_SIZE}). Defaults to the connector's configured page size.`,
506
+ };
507
+ const START_CURSOR_PROPERTY = {
508
+ type: "string",
509
+ description: "Opaque next_cursor from the previous response. Pass it back verbatim.",
510
+ };
511
+ const PROPERTY_SELECT = {
512
+ type: "array",
513
+ items: { type: "string" },
514
+ description: "Return only these property names. Omit for all. The cheapest way to shrink a result.",
515
+ };
516
+ function listOutputSchema(itemSchema) {
517
+ return {
518
+ type: "object",
519
+ properties: {
520
+ results: { type: "array", items: itemSchema },
521
+ has_more: { type: "boolean", description: "True when another page exists." },
522
+ next_cursor: { type: ["string", "null"], description: "Pass as start_cursor to fetch the next page." },
523
+ },
524
+ required: ["results", "has_more", "next_cursor"],
525
+ };
526
+ }
527
+ const PAGE_OUTPUT_SCHEMA = {
528
+ type: "object",
529
+ description: "Projected page. With raw: true this is Notion's full page object instead.",
530
+ properties: {
531
+ id: { type: "string" },
532
+ object: { type: "string" },
533
+ title: { type: "string", description: "Plain text of the title property." },
534
+ url: { type: ["string", "null"] },
535
+ parent: {
536
+ type: ["object", "null"],
537
+ properties: { type: { type: "string" }, id: { type: ["string", "null"] } },
538
+ required: ["type", "id"],
539
+ },
540
+ icon: { type: ["string", "null"], description: "Emoji or icon URL." },
541
+ created_time: { type: ["string", "null"] },
542
+ last_edited_time: { type: ["string", "null"] },
543
+ created_by: { type: ["object", "null"] },
544
+ last_edited_by: { type: ["object", "null"] },
545
+ in_trash: { type: "boolean" },
546
+ is_archived: { type: "boolean" },
547
+ properties: {
548
+ type: "object",
549
+ description: "Property name to flattened value: text for title/rich_text, name for select/status, array of names for multi_select, array of page ids for relation.",
550
+ },
551
+ truncated_properties: {
552
+ type: "array",
553
+ items: {
554
+ type: "object",
555
+ properties: {
556
+ name: { type: "string" },
557
+ id: { type: ["string", "null"], description: "The property_id in the property-item path." },
558
+ },
559
+ required: ["name", "id"],
560
+ },
561
+ description: "Properties Notion truncated at 25 entries. Read each in full with notion_api_read GET /v1/pages/{page_id}/properties/{property_id}.",
562
+ },
563
+ },
564
+ required: ["id", "title", "properties"],
565
+ };
566
+ const BLOCK_OUTPUT_SCHEMA = {
567
+ type: "object",
568
+ properties: {
569
+ id: { type: "string" },
570
+ type: { type: "string" },
571
+ depth: { type: "integer", description: "0 for direct children, 1 for their children, and so on." },
572
+ text: { type: "string", description: "Plain text of the block." },
573
+ has_children: { type: "boolean" },
574
+ checked: { type: "boolean", description: "to_do blocks only." },
575
+ language: { type: ["string", "null"], description: "code blocks only." },
576
+ url: { type: ["string", "null"], description: "Media and link blocks." },
577
+ cells: { type: "array", items: { type: "string" }, description: "table_row blocks only." },
578
+ icon: { type: ["string", "null"], description: "callout blocks only." },
579
+ raw: {
580
+ type: "object",
581
+ description: "The block type's untouched payload, present only for types this projection does not model and whose content is not plain rich text.",
582
+ },
583
+ },
584
+ required: ["id", "type", "depth", "text", "has_children"],
585
+ };
586
+ function resolvePageSize(requested, fallback) {
587
+ if (typeof requested === "number" && Number.isFinite(requested)) {
588
+ return Math.min(Math.max(Math.trunc(requested), 1), MAX_PAGE_SIZE);
589
+ }
590
+ return fallback;
591
+ }
592
+ function listEnvelope(payload, results) {
593
+ return { results, has_more: payload?.has_more === true, next_cursor: payload?.next_cursor ?? null };
594
+ }
595
+ function mappedListEnvelope(payload, project) {
596
+ return listEnvelope(payload, (payload?.results ?? []).map(project));
597
+ }
598
+ function pagination(args, defaultPageSize) {
599
+ return {
600
+ page_size: resolvePageSize(args.page_size, defaultPageSize),
601
+ ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
602
+ };
603
+ }
604
+ /** Exactly-one-of validation, phrased so the agent knows what to send next. */
605
+ function requireExactlyOne(provided, hint) {
606
+ const present = provided.filter(([, value]) => value !== undefined && value !== null && value !== "");
607
+ if (present.length !== 1) {
608
+ invalid(`Provide exactly one of ${provided.map(([name]) => name).join(", ")}. ${hint}`);
609
+ }
610
+ return present[0];
611
+ }
612
+ /**
613
+ * The named tools. Each earns its catalog bytes over the generic set (H14):
614
+ * the reads flatten Notion's wrapped properties, rich-text runs, and blocks
615
+ * (and walk nesting under a request ceiling); the two writes author Notion's
616
+ * rich-text and block shapes from plain text. Thin wrappers the generic tools
617
+ * replace one for one (database, users, comments, property items, property
618
+ * updates, trash) are gone; the guide names their operations.
619
+ */
620
+ function namedTools(defaultPageSize) {
621
+ return [
622
+ {
623
+ name: "integration_search",
624
+ description: "Find pages and data sources by title across everything shared with this integration. Never searches page content — use integration_query_data_source for rows inside a database. Returns identity fields only.",
625
+ annotations: { readOnlyHint: true },
626
+ inputSchema: {
627
+ type: "object",
628
+ required: [],
629
+ properties: {
630
+ query: {
631
+ type: "string",
632
+ description: "Title substring to match. Omit to list everything shared with the integration.",
633
+ },
634
+ object_type: {
635
+ type: "string",
636
+ enum: ["page", "data_source"],
637
+ description: "Restrict results to pages or to data sources. Omit for both.",
638
+ },
639
+ sort: {
640
+ type: "string",
641
+ enum: ["last_edited_desc", "last_edited_asc", "relevance"],
642
+ description: "Result ordering. Defaults to Notion's relevance order.",
643
+ },
644
+ page_size: PAGE_SIZE_PROPERTY,
645
+ start_cursor: START_CURSOR_PROPERTY,
646
+ raw: RAW_PROPERTY,
647
+ },
648
+ additionalProperties: false,
649
+ },
650
+ outputSchema: listOutputSchema({
651
+ type: "object",
652
+ properties: {
653
+ id: { type: "string" },
654
+ object: { type: "string", description: '"page" or "data_source".' },
655
+ title: { type: "string" },
656
+ url: { type: ["string", "null"] },
657
+ parent: { type: ["object", "null"] },
658
+ database_id: { type: ["string", "null"], description: "Data source hits only: the containing database." },
659
+ last_edited_time: { type: ["string", "null"] },
660
+ },
661
+ required: ["id", "object", "title"],
662
+ }),
663
+ handler: async (args, ctx) => {
664
+ const body = pagination(args, defaultPageSize);
665
+ if (args.query)
666
+ body["query"] = args.query;
667
+ if (args.object_type)
668
+ body["filter"] = { property: "object", value: args.object_type };
669
+ if (args.sort === "relevance") {
670
+ body["sort"] = { property: "relevance" };
671
+ }
672
+ else if (args.sort) {
673
+ body["sort"] = {
674
+ timestamp: "last_edited_time",
675
+ direction: args.sort === "last_edited_asc" ? "ascending" : "descending",
676
+ };
677
+ }
678
+ const payload = await notion(ctx, "POST", "/v1/search", { body });
679
+ if (args.raw)
680
+ return payload;
681
+ return mappedListEnvelope(payload, projectSearchHit);
682
+ },
683
+ },
684
+ {
685
+ name: "integration_get_page",
686
+ description: "Fetch one page's metadata and flattened property values by id. Returns the page's properties, not its body content — use integration_get_page_content for the blocks.",
687
+ annotations: { readOnlyHint: true },
688
+ inputSchema: {
689
+ type: "object",
690
+ properties: {
691
+ page_id: { type: "string", description: "Notion page id, with or without dashes." },
692
+ properties: PROPERTY_SELECT,
693
+ raw: RAW_PROPERTY,
694
+ },
695
+ required: ["page_id"],
696
+ additionalProperties: false,
697
+ },
698
+ outputSchema: PAGE_OUTPUT_SCHEMA,
699
+ handler: async (args, ctx) => {
700
+ const payload = await notion(ctx, "GET", `/v1/pages/${id(args.page_id)}`);
701
+ if (args.raw)
702
+ return payload;
703
+ return projectPage(payload, args.properties);
704
+ },
705
+ },
706
+ {
707
+ name: "integration_get_page_content",
708
+ // `raw: true` returns the requested level exactly as Notion sent it and
709
+ // does not walk nested children, so `depth` is ignored alongside it: a
710
+ // raw read of a deep page yields one level.
711
+ description: "Read a page's body as a flat list of blocks reduced to plain text. Each block keeps its id, type, and depth so it can be quoted, appended after, or drilled into. Nested content requires depth > 0.",
712
+ annotations: { readOnlyHint: true },
713
+ inputSchema: {
714
+ type: "object",
715
+ properties: {
716
+ block_id: {
717
+ type: "string",
718
+ description: "Page id, or any block id to read that block's children. A page id is a valid block id.",
719
+ },
720
+ depth: {
721
+ type: "integer",
722
+ minimum: 0,
723
+ maximum: 2,
724
+ description: "How many levels of nested children to follow. 0 (default) returns direct children only. Each level multiplies downstream requests.",
725
+ },
726
+ page_size: PAGE_SIZE_PROPERTY,
727
+ start_cursor: START_CURSOR_PROPERTY,
728
+ raw: {
729
+ ...RAW_PROPERTY,
730
+ description: `${RAW_PROPERTY["description"]} It returns this one level exactly as Notion sent it and does not walk nested children, so depth is ignored alongside it — read a child block_id directly instead.`,
731
+ },
732
+ },
733
+ required: ["block_id"],
734
+ additionalProperties: false,
735
+ },
736
+ outputSchema: {
737
+ ...listOutputSchema(BLOCK_OUTPUT_SCHEMA),
738
+ properties: {
739
+ ...listOutputSchema(BLOCK_OUTPUT_SCHEMA)["properties"],
740
+ truncated: {
741
+ type: "boolean",
742
+ description: "True when the nested walk stopped at its request ceiling. Some descendants are missing; re-read a specific block_id to continue.",
743
+ },
744
+ },
745
+ required: ["results", "has_more", "next_cursor", "truncated"],
746
+ },
747
+ handler: async (args, ctx) => {
748
+ const top = await notion(ctx, "GET", `/v1/blocks/${id(args.block_id)}/children`, {
749
+ query: pagination(args, defaultPageSize),
750
+ });
751
+ if (args.raw)
752
+ return top;
753
+ const maxDepth = typeof args.depth === "number" ? args.depth : 0;
754
+ let spent = 1;
755
+ let truncated = false;
756
+ const results = [];
757
+ const walk = async (blocks, depth) => {
758
+ for (const block of blocks) {
759
+ results.push(projectBlock(block, depth));
760
+ if (depth >= maxDepth || block?.has_children !== true)
761
+ continue;
762
+ if (spent >= MAX_CONTENT_REQUESTS) {
763
+ truncated = true;
764
+ continue;
765
+ }
766
+ spent += 1;
767
+ const child = await notion(ctx, "GET", `/v1/blocks/${id(block.id)}/children`, {
768
+ query: { page_size: MAX_PAGE_SIZE },
769
+ });
770
+ // Nested levels take their first page only; a block with more than
771
+ // 100 children is re-read directly rather than paged here.
772
+ if (child["has_more"] === true)
773
+ truncated = true;
774
+ await walk(child["results"] ?? [], depth + 1);
775
+ }
776
+ };
777
+ await walk(top["results"] ?? [], 0);
778
+ return { ...listEnvelope(top, results), truncated };
779
+ },
780
+ },
781
+ {
782
+ name: "integration_get_data_source_schema",
783
+ description: "List a data source's properties with their ids, types, and select/status options. Read this before filtering, sorting, or writing — filters and property names that do not match the schema exactly are rejected.",
784
+ annotations: { readOnlyHint: true },
785
+ inputSchema: {
786
+ type: "object",
787
+ properties: {
788
+ data_source_id: {
789
+ type: "string",
790
+ description: "Data source id from integration_search or a database's data_sources, not a database id.",
791
+ },
792
+ raw: RAW_PROPERTY,
793
+ },
794
+ required: ["data_source_id"],
795
+ additionalProperties: false,
796
+ },
797
+ outputSchema: {
798
+ type: "object",
799
+ properties: {
800
+ id: { type: "string" },
801
+ name: { type: "string" },
802
+ database_id: { type: ["string", "null"] },
803
+ title_property: {
804
+ type: ["string", "null"],
805
+ description: "Name of the title-typed property. integration_create_page needs this to title a row.",
806
+ },
807
+ properties: {
808
+ type: "object",
809
+ description: "Property name to { id, type, options?, relation_data_source_id? }.",
810
+ },
811
+ },
812
+ required: ["id", "name", "properties"],
813
+ },
814
+ handler: async (args, ctx) => {
815
+ const payload = await notion(ctx, "GET", `/v1/data_sources/${id(args.data_source_id)}`);
816
+ if (args.raw)
817
+ return payload;
818
+ const properties = {};
819
+ let titleProperty = null;
820
+ for (const [name, property] of Object.entries((payload["properties"] ?? {}))) {
821
+ properties[name] = projectSchemaProperty(property);
822
+ if (property?.type === "title")
823
+ titleProperty = name;
824
+ }
825
+ return {
826
+ id: payload["id"] ?? null,
827
+ name: plainText(payload["title"]) || (payload["name"] ?? ""),
828
+ database_id: payload["parent"]?.database_id ?? null,
829
+ title_property: titleProperty,
830
+ properties,
831
+ };
832
+ },
833
+ },
834
+ {
835
+ name: "integration_query_data_source",
836
+ description: "List rows in a data source with optional filtering and sorting, returning each row's properties already flattened. Requires a data_source_id, never a database_id. Narrow with the properties argument to keep results small.",
837
+ annotations: { readOnlyHint: true },
838
+ inputSchema: {
839
+ type: "object",
840
+ properties: {
841
+ data_source_id: {
842
+ type: "string",
843
+ description: "Data source id from integration_search or a database's data_sources.",
844
+ },
845
+ filter: {
846
+ type: "object",
847
+ description: 'Notion filter object, passed through unchanged. Single condition: {"property":"Status","status":{"equals":"Done"}}. Compound: {"and":[...]} or {"or":[...]}. Property names must match integration_get_data_source_schema exactly.',
848
+ },
849
+ sorts: {
850
+ type: "array",
851
+ description: "Sort order, applied in sequence.",
852
+ items: {
853
+ type: "object",
854
+ properties: {
855
+ property: { type: "string", description: "Property name to sort by." },
856
+ timestamp: {
857
+ type: "string",
858
+ enum: ["created_time", "last_edited_time"],
859
+ description: "Sort by a timestamp instead of a property.",
860
+ },
861
+ direction: {
862
+ type: "string",
863
+ enum: ["ascending", "descending"],
864
+ description: "Direction for this sort.",
865
+ },
866
+ },
867
+ additionalProperties: false,
868
+ },
869
+ },
870
+ properties: PROPERTY_SELECT,
871
+ page_size: PAGE_SIZE_PROPERTY,
872
+ start_cursor: START_CURSOR_PROPERTY,
873
+ raw: RAW_PROPERTY,
874
+ },
875
+ required: ["data_source_id"],
876
+ additionalProperties: false,
877
+ },
878
+ outputSchema: listOutputSchema(PAGE_OUTPUT_SCHEMA),
879
+ handler: async (args, ctx) => {
880
+ const body = pagination(args, defaultPageSize);
881
+ if (args.filter)
882
+ body["filter"] = args.filter;
883
+ if (args.sorts)
884
+ body["sorts"] = args.sorts;
885
+ const payload = await notion(ctx, "POST", `/v1/data_sources/${id(args.data_source_id)}/query`, { body });
886
+ if (args.raw)
887
+ return payload;
888
+ return mappedListEnvelope(payload, (row) => projectPage(row, args.properties));
889
+ },
890
+ },
891
+ {
892
+ name: "integration_create_page",
893
+ description: "Create a page, either as a child of another page or as a row in a data source. Notion has no idempotency key: a retried create makes a second page, so confirm with search before repeating one.",
894
+ annotations: { readOnlyHint: false },
895
+ inputSchema: {
896
+ // A deliberate subset of the 2026-03-11 create contract, reviewed after
897
+ // the 0.17.0 drift check (#408). Workspace-private pages, templates,
898
+ // page placement, and the expanded icon/cover forms stay out: they
899
+ // change ownership, start asynchronous content work, control ordering,
900
+ // or depend on file surfaces, none of which extend page/row authoring.
901
+ // notion_api_write reaches the full contract.
902
+ required: [],
903
+ type: "object",
904
+ properties: {
905
+ parent_page_id: {
906
+ type: "string",
907
+ description: "Create as a child page of this page. Exactly one parent id, this or parent_data_source_id.",
908
+ },
909
+ parent_data_source_id: {
910
+ type: "string",
911
+ description: "Create as a row in this data source, not a database id. Exactly one parent id, this or parent_page_id.",
912
+ },
913
+ title: { type: "string", description: "Plain-text title." },
914
+ title_property: {
915
+ type: "string",
916
+ description: 'Name of the title-typed property, from integration_get_data_source_schema. Required in practice for a data-source parent, whose title column is rarely called "title". Defaults to "title", which is the only valid key under a page parent.',
917
+ },
918
+ properties: {
919
+ type: "object",
920
+ description: 'Additional Notion property values, keyed by property name and in Notion\'s own wrapped form, e.g. {"Status":{"status":{"name":"Todo"}}}. Read integration_get_data_source_schema first.',
921
+ },
922
+ markdown: {
923
+ type: "string",
924
+ description: "Page body as Notion-flavored Markdown. Mutually exclusive with children.",
925
+ },
926
+ children: {
927
+ type: "array",
928
+ maxItems: MAX_CHILDREN_PER_REQUEST,
929
+ description: "Page body as raw Notion block objects. Mutually exclusive with markdown.",
930
+ items: { type: "object" },
931
+ },
932
+ icon: { type: "string", description: "Emoji to use as the page icon." },
933
+ },
934
+ additionalProperties: false,
935
+ },
936
+ outputSchema: PAGE_OUTPUT_SCHEMA,
937
+ handler: async (args, ctx) => {
938
+ const [parentKey, parentValue] = requireExactlyOne([
939
+ ["parent_page_id", args.parent_page_id],
940
+ ["parent_data_source_id", args.parent_data_source_id],
941
+ ], "A page needs exactly one parent, and a data source is addressed by its data_source_id — never by a database_id.");
942
+ if (args.markdown !== undefined && args.children !== undefined) {
943
+ invalid("Provide either markdown or children for the page body, not both.");
944
+ }
945
+ const properties = { ...args.properties };
946
+ if (args.title !== undefined)
947
+ properties[args.title_property ?? "title"] = { title: richText(args.title) };
948
+ const body = {
949
+ parent: parentKey === "parent_page_id"
950
+ ? { type: "page_id", page_id: parentValue }
951
+ : { type: "data_source_id", data_source_id: parentValue },
952
+ properties,
953
+ };
954
+ if (args.markdown !== undefined)
955
+ body["markdown"] = args.markdown;
956
+ if (args.children !== undefined)
957
+ body["children"] = args.children;
958
+ if (args.icon !== undefined)
959
+ body["icon"] = { type: "emoji", emoji: args.icon };
960
+ return projectPage(await notion(ctx, "POST", "/v1/pages", { body }));
961
+ },
962
+ },
963
+ {
964
+ name: "integration_append_blocks",
965
+ description: "Append content to the end of a page or block, or insert it at a chosen position. Appending only adds: existing blocks are never moved or replaced, and an appended block cannot be relocated later through the API.",
966
+ annotations: { readOnlyHint: false },
967
+ inputSchema: {
968
+ type: "object",
969
+ properties: {
970
+ block_id: { type: "string", description: "Page id or block id to append into." },
971
+ text: {
972
+ type: "array",
973
+ items: { type: "string" },
974
+ maxItems: MAX_CHILDREN_PER_REQUEST,
975
+ description: "Plain-text paragraphs, one block each. Use exactly one of text, checklist, or children.",
976
+ },
977
+ checklist: {
978
+ type: "array",
979
+ items: { type: "string" },
980
+ maxItems: MAX_CHILDREN_PER_REQUEST,
981
+ description: "Plain-text to-do items, one unchecked to_do block each.",
982
+ },
983
+ children: {
984
+ type: "array",
985
+ items: { type: "object" },
986
+ maxItems: MAX_CHILDREN_PER_REQUEST,
987
+ description: "Raw Notion block objects, for anything paragraphs and to-dos cannot express.",
988
+ },
989
+ position: {
990
+ type: "string",
991
+ enum: ["end", "start", "after_block"],
992
+ description: 'Where to insert. Defaults to "end". "after_block" requires after_block_id.',
993
+ },
994
+ after_block_id: {
995
+ type: "string",
996
+ description: 'Insert directly after this block when position is "after_block".',
997
+ },
998
+ },
999
+ required: ["block_id"],
1000
+ additionalProperties: false,
1001
+ },
1002
+ outputSchema: {
1003
+ type: "object",
1004
+ properties: {
1005
+ appended: { type: "integer", description: "How many blocks were created." },
1006
+ results: { type: "array", items: BLOCK_OUTPUT_SCHEMA },
1007
+ },
1008
+ required: ["appended", "results"],
1009
+ },
1010
+ handler: async (args, ctx) => {
1011
+ const [kind, value] = requireExactlyOne([
1012
+ ["text", args.text],
1013
+ ["checklist", args.checklist],
1014
+ ["children", args.children],
1015
+ ], "Use text for plain paragraphs, checklist for to-do items, or children for raw Notion blocks.");
1016
+ const children = kind === "text"
1017
+ ? value.map((line) => ({
1018
+ object: "block",
1019
+ type: "paragraph",
1020
+ paragraph: { rich_text: richText(line) },
1021
+ }))
1022
+ : kind === "checklist"
1023
+ ? value.map((line) => ({
1024
+ object: "block",
1025
+ type: "to_do",
1026
+ to_do: { rich_text: richText(line), checked: false },
1027
+ }))
1028
+ : value;
1029
+ if (children.length === 0)
1030
+ invalid("Nothing to append: provide at least one block.");
1031
+ const body = { children };
1032
+ if (args.position === "after_block") {
1033
+ if (!args.after_block_id)
1034
+ invalid('position "after_block" requires after_block_id.');
1035
+ body["position"] = { type: "after_block", after_block: { id: args.after_block_id } };
1036
+ }
1037
+ else if (args.position) {
1038
+ body["position"] = { type: args.position };
1039
+ }
1040
+ const payload = await notion(ctx, "PATCH", `/v1/blocks/${id(args.block_id)}/children`, { body });
1041
+ const results = (payload["results"] ?? []).map((block) => projectBlock(block, 0));
1042
+ return { appended: results.length, results };
1043
+ },
1044
+ },
1045
+ ];
1046
+ }
1047
+ /** Notion's generic and named REST tools and its credential test. */
1048
+ export function notionRest(defaultPageSize) {
1049
+ return {
1050
+ tools: [...restTools(VENDOR), ...namedTools(defaultPageSize)],
1051
+ async testCredential(value, ctx) {
1052
+ // Notion has no token-introspection endpoint for internal integrations;
1053
+ // identifying the bot is the cheapest call that proves the token is live.
1054
+ try {
1055
+ const payload = await notion({ ...ctx, credential: { get: async () => value, getAll: async () => ({ value }) } }, "GET", "/v1/users/me");
1056
+ const name = payload["bot"]?.workspace_name ?? payload["name"] ?? "Notion";
1057
+ return { ok: true, message: `Authenticated as ${name}.` };
1058
+ }
1059
+ catch (error) {
1060
+ return {
1061
+ ok: false,
1062
+ message: error instanceof ConnectorCallError ? error.message : "Notion rejected the token.",
1063
+ };
1064
+ }
1065
+ },
1066
+ };
1067
+ }