@zackbart/connecta 0.15.1 → 0.16.1

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 (335) hide show
  1. package/AGENTS.md +40 -20
  2. package/CHANGELOG.md +653 -0
  3. package/README.md +25 -5
  4. package/bin/connecta.mjs +44 -3
  5. package/dist/access-tokens.d.ts +0 -1
  6. package/dist/access-tokens.js +0 -1
  7. package/dist/activity.d.ts +50 -2
  8. package/dist/activity.js +34 -1
  9. package/dist/apps-shell.d.ts +0 -1
  10. package/dist/apps-shell.js +0 -1
  11. package/dist/auth/bearer.d.ts +0 -1
  12. package/dist/auth/bearer.js +0 -1
  13. package/dist/auth/clerk.d.ts +0 -1
  14. package/dist/auth/clerk.js +45 -9
  15. package/dist/auth/downstream-oauth.d.ts +0 -1
  16. package/dist/auth/downstream-oauth.js +0 -1
  17. package/dist/call-admission.d.ts +0 -1
  18. package/dist/call-admission.js +0 -1
  19. package/dist/catalog-drift.d.ts +99 -0
  20. package/dist/catalog-drift.js +251 -0
  21. package/dist/catalog-fingerprint.d.ts +0 -1
  22. package/dist/catalog-fingerprint.js +0 -1
  23. package/dist/catalog-limits.d.ts +0 -1
  24. package/dist/catalog-limits.js +0 -1
  25. package/dist/catalog-service.d.ts +0 -1
  26. package/dist/catalog-service.js +41 -5
  27. package/dist/catalog.d.ts +11 -1
  28. package/dist/catalog.js +20 -1
  29. package/dist/concurrency.d.ts +0 -1
  30. package/dist/concurrency.js +0 -1
  31. package/dist/connector-scope.d.ts +0 -1
  32. package/dist/connector-scope.js +0 -1
  33. package/dist/connectors/api.d.ts +29 -21
  34. package/dist/connectors/api.js +45 -19
  35. package/dist/connectors/guarded-fetch.d.ts +106 -0
  36. package/dist/connectors/guarded-fetch.js +289 -0
  37. package/dist/connectors/remote-mcp.d.ts +0 -1
  38. package/dist/connectors/remote-mcp.js +0 -1
  39. package/dist/credentials.d.ts +0 -1
  40. package/dist/credentials.js +0 -1
  41. package/dist/errors.d.ts +30 -3
  42. package/dist/errors.js +1 -1
  43. package/dist/execute.d.ts +0 -1
  44. package/dist/execute.js +0 -1
  45. package/dist/executor-admission.d.ts +11 -1
  46. package/dist/executor-admission.js +28 -1
  47. package/dist/executor-result.d.ts +0 -1
  48. package/dist/executor-result.js +0 -1
  49. package/dist/executors/quickjs-child.d.ts +0 -1
  50. package/dist/executors/quickjs-child.js +0 -1
  51. package/dist/executors/quickjs-protocol.d.ts +0 -1
  52. package/dist/executors/quickjs-protocol.js +0 -1
  53. package/dist/executors/quickjs-runtime.d.ts +0 -1
  54. package/dist/executors/quickjs-runtime.js +0 -1
  55. package/dist/executors/quickjs.d.ts +0 -1
  56. package/dist/executors/quickjs.js +2 -1
  57. package/dist/favicon.d.ts +0 -1
  58. package/dist/favicon.js +0 -1
  59. package/dist/index.d.ts +2 -3
  60. package/dist/index.js +24 -7
  61. package/dist/invocation.d.ts +0 -1
  62. package/dist/invocation.js +0 -1
  63. package/dist/json-schema.d.ts +0 -1
  64. package/dist/json-schema.js +0 -1
  65. package/dist/mcp-result.d.ts +0 -1
  66. package/dist/mcp-result.js +0 -1
  67. package/dist/meta-tools.d.ts +0 -1
  68. package/dist/meta-tools.js +0 -1
  69. package/dist/node.d.ts +0 -1
  70. package/dist/node.js +0 -1
  71. package/dist/operator-ui/generated.d.ts +2 -3
  72. package/dist/operator-ui/generated.js +3 -4
  73. package/dist/operator-ui/model.d.ts +10 -1
  74. package/dist/operator-ui/model.js +0 -1
  75. package/dist/operator-ui/view.d.ts +167 -0
  76. package/dist/operator-ui/view.js +270 -0
  77. package/dist/providers/cloudflare.d.ts +0 -1
  78. package/dist/providers/cloudflare.js +261 -299
  79. package/dist/providers/linear.d.ts +26 -5
  80. package/dist/providers/linear.js +32 -61
  81. package/dist/providers/mixpanel.d.ts +35 -4
  82. package/dist/providers/mixpanel.js +53 -79
  83. package/dist/providers/notion.d.ts +0 -1
  84. package/dist/providers/notion.js +87 -42
  85. package/dist/providers/stripe.d.ts +13 -1
  86. package/dist/providers/stripe.js +31 -60
  87. package/dist/registry.d.ts +34 -2
  88. package/dist/registry.js +85 -5
  89. package/dist/retired-toolkits.d.ts +0 -1
  90. package/dist/retired-toolkits.js +0 -1
  91. package/dist/routes/access-tokens.d.ts +0 -1
  92. package/dist/routes/access-tokens.js +0 -1
  93. package/dist/routes/activity.d.ts +0 -1
  94. package/dist/routes/activity.js +0 -1
  95. package/dist/routes/credentials.d.ts +0 -1
  96. package/dist/routes/credentials.js +0 -1
  97. package/dist/routes/mcp.d.ts +0 -1
  98. package/dist/routes/mcp.js +0 -1
  99. package/dist/routes/oauth.d.ts +0 -1
  100. package/dist/routes/oauth.js +0 -1
  101. package/dist/routes/shared.d.ts +2 -1
  102. package/dist/routes/shared.js +0 -1
  103. package/dist/routes/ui.d.ts +0 -1
  104. package/dist/routes/ui.js +0 -1
  105. package/dist/server.d.ts +0 -1
  106. package/dist/server.js +11 -1
  107. package/dist/skills.d.ts +0 -1
  108. package/dist/skills.js +0 -1
  109. package/dist/storage/file.d.ts +0 -1
  110. package/dist/storage/file.js +0 -1
  111. package/dist/storage/memory.d.ts +0 -1
  112. package/dist/storage/memory.js +0 -1
  113. package/dist/timeout.d.ts +0 -1
  114. package/dist/timeout.js +0 -1
  115. package/dist/tool-safety.d.ts +0 -1
  116. package/dist/tool-safety.js +0 -1
  117. package/dist/types.d.ts +45 -1
  118. package/dist/types.js +0 -1
  119. package/dist/ui.d.ts +0 -1
  120. package/dist/ui.js +13 -137
  121. package/dist/validate.d.ts +13 -15
  122. package/dist/validate.js +24 -11
  123. package/dist/version.d.ts +1 -2
  124. package/dist/version.js +1 -2
  125. package/documentation/architecture.md +186 -5
  126. package/documentation/auth.md +11 -0
  127. package/documentation/call-admission.md +142 -6
  128. package/documentation/cloudflare.md +92 -10
  129. package/documentation/code-first-exploration.md +2 -2
  130. package/documentation/code-mode.md +14 -14
  131. package/documentation/connector-guides.md +172 -5
  132. package/documentation/connectors.md +89 -0
  133. package/documentation/linear.md +20 -2
  134. package/documentation/mcp-ui-design.md +2 -2
  135. package/documentation/meta-tools.md +7 -1
  136. package/documentation/mixpanel.md +42 -9
  137. package/documentation/notion.md +23 -2
  138. package/documentation/operations.md +325 -5
  139. package/documentation/operator-ui.md +120 -5
  140. package/documentation/provider-audit.md +160 -0
  141. package/documentation/provider-conventions.md +667 -0
  142. package/documentation/request-admission.md +167 -5
  143. package/documentation/rich-output-design.md +1 -2
  144. package/documentation/stripe.md +15 -0
  145. package/documentation/upgrading.md +454 -0
  146. package/ethos.md +14 -9
  147. package/examples/worker/README.md +102 -12
  148. package/examples/worker/src/index.ts +46 -1
  149. package/examples/worker/wrangler.jsonc +14 -1
  150. package/package.json +10 -6
  151. package/templates/node/.dockerignore +10 -0
  152. package/templates/node/.env.example +40 -3
  153. package/templates/node/AGENTS.md +16 -1
  154. package/templates/node/Dockerfile +49 -0
  155. package/templates/node/README.md +117 -2
  156. package/templates/node/docker-compose.yml +39 -0
  157. package/templates/node/package.json +1 -1
  158. package/templates/node/src/file-activity.ts +182 -0
  159. package/templates/node/src/index.ts +80 -4
  160. package/assets/connecta-clay-hero.png +0 -0
  161. package/dist/access-tokens.d.ts.map +0 -1
  162. package/dist/access-tokens.js.map +0 -1
  163. package/dist/activity.d.ts.map +0 -1
  164. package/dist/activity.js.map +0 -1
  165. package/dist/apps-shell.d.ts.map +0 -1
  166. package/dist/apps-shell.js.map +0 -1
  167. package/dist/auth/bearer.d.ts.map +0 -1
  168. package/dist/auth/bearer.js.map +0 -1
  169. package/dist/auth/clerk.d.ts.map +0 -1
  170. package/dist/auth/clerk.js.map +0 -1
  171. package/dist/auth/downstream-oauth.d.ts.map +0 -1
  172. package/dist/auth/downstream-oauth.js.map +0 -1
  173. package/dist/call-admission.d.ts.map +0 -1
  174. package/dist/call-admission.js.map +0 -1
  175. package/dist/catalog-fingerprint.d.ts.map +0 -1
  176. package/dist/catalog-fingerprint.js.map +0 -1
  177. package/dist/catalog-limits.d.ts.map +0 -1
  178. package/dist/catalog-limits.js.map +0 -1
  179. package/dist/catalog-service.d.ts.map +0 -1
  180. package/dist/catalog-service.js.map +0 -1
  181. package/dist/catalog.d.ts.map +0 -1
  182. package/dist/catalog.js.map +0 -1
  183. package/dist/concurrency.d.ts.map +0 -1
  184. package/dist/concurrency.js.map +0 -1
  185. package/dist/connector-scope.d.ts.map +0 -1
  186. package/dist/connector-scope.js.map +0 -1
  187. package/dist/connectors/api.d.ts.map +0 -1
  188. package/dist/connectors/api.js.map +0 -1
  189. package/dist/connectors/remote-mcp.d.ts.map +0 -1
  190. package/dist/connectors/remote-mcp.js.map +0 -1
  191. package/dist/credentials.d.ts.map +0 -1
  192. package/dist/credentials.js.map +0 -1
  193. package/dist/errors.d.ts.map +0 -1
  194. package/dist/errors.js.map +0 -1
  195. package/dist/execute.d.ts.map +0 -1
  196. package/dist/execute.js.map +0 -1
  197. package/dist/executor-admission.d.ts.map +0 -1
  198. package/dist/executor-admission.js.map +0 -1
  199. package/dist/executor-result.d.ts.map +0 -1
  200. package/dist/executor-result.js.map +0 -1
  201. package/dist/executors/quickjs-child.d.ts.map +0 -1
  202. package/dist/executors/quickjs-child.js.map +0 -1
  203. package/dist/executors/quickjs-protocol.d.ts.map +0 -1
  204. package/dist/executors/quickjs-protocol.js.map +0 -1
  205. package/dist/executors/quickjs-runtime.d.ts.map +0 -1
  206. package/dist/executors/quickjs-runtime.js.map +0 -1
  207. package/dist/executors/quickjs.d.ts.map +0 -1
  208. package/dist/executors/quickjs.js.map +0 -1
  209. package/dist/favicon.d.ts.map +0 -1
  210. package/dist/favicon.js.map +0 -1
  211. package/dist/index.d.ts.map +0 -1
  212. package/dist/index.js.map +0 -1
  213. package/dist/invocation.d.ts.map +0 -1
  214. package/dist/invocation.js.map +0 -1
  215. package/dist/json-schema.d.ts.map +0 -1
  216. package/dist/json-schema.js.map +0 -1
  217. package/dist/mcp-result.d.ts.map +0 -1
  218. package/dist/mcp-result.js.map +0 -1
  219. package/dist/meta-tools.d.ts.map +0 -1
  220. package/dist/meta-tools.js.map +0 -1
  221. package/dist/node.d.ts.map +0 -1
  222. package/dist/node.js.map +0 -1
  223. package/dist/operator-ui/generated.d.ts.map +0 -1
  224. package/dist/operator-ui/generated.js.map +0 -1
  225. package/dist/operator-ui/model.d.ts.map +0 -1
  226. package/dist/operator-ui/model.js.map +0 -1
  227. package/dist/providers/cloudflare.d.ts.map +0 -1
  228. package/dist/providers/cloudflare.js.map +0 -1
  229. package/dist/providers/linear.d.ts.map +0 -1
  230. package/dist/providers/linear.js.map +0 -1
  231. package/dist/providers/mixpanel.d.ts.map +0 -1
  232. package/dist/providers/mixpanel.js.map +0 -1
  233. package/dist/providers/notion.d.ts.map +0 -1
  234. package/dist/providers/notion.js.map +0 -1
  235. package/dist/providers/stripe.d.ts.map +0 -1
  236. package/dist/providers/stripe.js.map +0 -1
  237. package/dist/registry.d.ts.map +0 -1
  238. package/dist/registry.js.map +0 -1
  239. package/dist/retired-toolkits.d.ts.map +0 -1
  240. package/dist/retired-toolkits.js.map +0 -1
  241. package/dist/routes/access-tokens.d.ts.map +0 -1
  242. package/dist/routes/access-tokens.js.map +0 -1
  243. package/dist/routes/activity.d.ts.map +0 -1
  244. package/dist/routes/activity.js.map +0 -1
  245. package/dist/routes/credentials.d.ts.map +0 -1
  246. package/dist/routes/credentials.js.map +0 -1
  247. package/dist/routes/mcp.d.ts.map +0 -1
  248. package/dist/routes/mcp.js.map +0 -1
  249. package/dist/routes/oauth.d.ts.map +0 -1
  250. package/dist/routes/oauth.js.map +0 -1
  251. package/dist/routes/shared.d.ts.map +0 -1
  252. package/dist/routes/shared.js.map +0 -1
  253. package/dist/routes/ui.d.ts.map +0 -1
  254. package/dist/routes/ui.js.map +0 -1
  255. package/dist/server.d.ts.map +0 -1
  256. package/dist/server.js.map +0 -1
  257. package/dist/skills.d.ts.map +0 -1
  258. package/dist/skills.js.map +0 -1
  259. package/dist/storage/file.d.ts.map +0 -1
  260. package/dist/storage/file.js.map +0 -1
  261. package/dist/storage/memory.d.ts.map +0 -1
  262. package/dist/storage/memory.js.map +0 -1
  263. package/dist/timeout.d.ts.map +0 -1
  264. package/dist/timeout.js.map +0 -1
  265. package/dist/tool-safety.d.ts.map +0 -1
  266. package/dist/tool-safety.js.map +0 -1
  267. package/dist/types.d.ts.map +0 -1
  268. package/dist/types.js.map +0 -1
  269. package/dist/ui.d.ts.map +0 -1
  270. package/dist/ui.js.map +0 -1
  271. package/dist/validate.d.ts.map +0 -1
  272. package/dist/validate.js.map +0 -1
  273. package/dist/version.d.ts.map +0 -1
  274. package/dist/version.js.map +0 -1
  275. package/examples/node/README.md +0 -52
  276. package/examples/node/src/index.ts +0 -71
  277. package/src/access-tokens.ts +0 -289
  278. package/src/activity.ts +0 -234
  279. package/src/apps-shell.ts +0 -370
  280. package/src/auth/bearer.ts +0 -61
  281. package/src/auth/clerk.ts +0 -553
  282. package/src/auth/downstream-oauth.ts +0 -575
  283. package/src/call-admission.ts +0 -521
  284. package/src/catalog-fingerprint.ts +0 -52
  285. package/src/catalog-limits.ts +0 -11
  286. package/src/catalog-service.ts +0 -1153
  287. package/src/catalog.ts +0 -790
  288. package/src/concurrency.ts +0 -39
  289. package/src/connector-scope.ts +0 -66
  290. package/src/connectors/api.ts +0 -159
  291. package/src/connectors/remote-mcp.ts +0 -1090
  292. package/src/credentials.ts +0 -428
  293. package/src/errors.ts +0 -364
  294. package/src/execute.ts +0 -1406
  295. package/src/executor-admission.ts +0 -328
  296. package/src/executor-result.ts +0 -107
  297. package/src/executors/quickjs-child.ts +0 -172
  298. package/src/executors/quickjs-protocol.ts +0 -99
  299. package/src/executors/quickjs-runtime.ts +0 -470
  300. package/src/executors/quickjs.ts +0 -776
  301. package/src/favicon.ts +0 -53
  302. package/src/index.ts +0 -666
  303. package/src/invocation.ts +0 -655
  304. package/src/json-schema.ts +0 -11
  305. package/src/mcp-result.ts +0 -43
  306. package/src/meta-tools.ts +0 -1607
  307. package/src/node.ts +0 -206
  308. package/src/operator-ui/browser.css +0 -423
  309. package/src/operator-ui/browser.ts +0 -1281
  310. package/src/operator-ui/generated.ts +0 -4
  311. package/src/operator-ui/model.ts +0 -111
  312. package/src/providers/cloudflare.ts +0 -3999
  313. package/src/providers/linear.ts +0 -302
  314. package/src/providers/mixpanel.ts +0 -229
  315. package/src/providers/notion.ts +0 -1879
  316. package/src/providers/stripe.ts +0 -307
  317. package/src/registry.ts +0 -1085
  318. package/src/retired-toolkits.ts +0 -14
  319. package/src/routes/access-tokens.ts +0 -115
  320. package/src/routes/activity.ts +0 -215
  321. package/src/routes/credentials.ts +0 -254
  322. package/src/routes/mcp.ts +0 -429
  323. package/src/routes/oauth.ts +0 -322
  324. package/src/routes/shared.ts +0 -247
  325. package/src/routes/ui.ts +0 -152
  326. package/src/server.ts +0 -195
  327. package/src/skills.ts +0 -271
  328. package/src/storage/file.ts +0 -123
  329. package/src/storage/memory.ts +0 -44
  330. package/src/timeout.ts +0 -84
  331. package/src/tool-safety.ts +0 -15
  332. package/src/types.ts +0 -523
  333. package/src/ui.ts +0 -744
  334. package/src/validate.ts +0 -387
  335. package/src/version.ts +0 -7
@@ -1,1879 +0,0 @@
1
- import { api, type ApiTool } from "../connectors/api.js";
2
- import { ConnectorCallError } from "../errors.js";
3
- import type {
4
- Connector,
5
- ConnectorCallAdmissionPolicy,
6
- ConnectorContext,
7
- JsonSchema,
8
- } from "../types.js";
9
-
10
- /** Notion's REST origin. Every tool below speaks to exactly this host. */
11
- export const NOTION_API_BASE_URL = "https://api.notion.com";
12
-
13
- /**
14
- * The API version this connection is written against, pinned deliberately.
15
- *
16
- * Notion's versions are date-named and old ones keep working indefinitely, so
17
- * a version override would look harmless. It is not: `2026-03-11` is the
18
- * version in which databases split into data sources, `archived` became
19
- * `in_trash`, and block append took a `position` object instead of `after`.
20
- * Every projection and every write body below assumes those shapes. A
21
- * deployment that pinned an older version would get silently wrong results
22
- * rather than a loud failure, so the option does not exist.
23
- *
24
- * Notion also ships additive changes to *every* version at once, which is why
25
- * the projections below degrade gracefully on unknown property and block types
26
- * rather than switching exhaustively.
27
- */
28
- export const NOTION_API_VERSION = "2026-03-11";
29
-
30
- /** Notion's hard cap on `page_size` for every paginated endpoint. */
31
- const MAX_PAGE_SIZE = 100;
32
-
33
- /** Lean by default: smaller than Notion's 100 so a first read stays cheap. */
34
- const DEFAULT_PAGE_SIZE = 25;
35
-
36
- /** Notion's cap on `children` per append, and on blocks per page create. */
37
- const MAX_CHILDREN_PER_REQUEST = 100;
38
-
39
- /**
40
- * Ceiling on downstream requests inside one `get_page_content` call.
41
- *
42
- * Recursive block traversal is the one tool here that can turn a single agent
43
- * call into unbounded provider traffic, and call admission meters *tool calls*,
44
- * not the fetches inside them. A depth-2 walk of a large page would otherwise
45
- * spend the whole rate-limit budget without ever being rejected. When the walk
46
- * hits this ceiling it stops and says so in `truncated`.
47
- */
48
- const MAX_CONTENT_REQUESTS = 20;
49
-
50
- /**
51
- * Approximates Notion's documented limit: "an average of three requests per
52
- * second, with some bursts beyond the average allowed" — expressed as a
53
- * rolling minute so short bursts pass and a sustained loop does not.
54
- *
55
- * `maxConcurrency` is the load-bearing half. A budget alone is an average, and
56
- * an averaged budget cannot stop a program from firing forty calls in the same
57
- * tick; the concurrency cap keeps a burst shaped roughly like the one Notion
58
- * documents. Neither half is a guarantee: admission meters *tool calls*, and
59
- * one admitted `get_page_content` can spend up to `MAX_CONTENT_REQUESTS`
60
- * fetches, so this bounds tool calls rather than requests. Declaring the cap is
61
- * also what makes the queue settings legal — the admission controller refuses
62
- * queue settings without a queue at construction.
63
- *
64
- * Per-runtime, like every connector budget: N Worker isolates or Node
65
- * processes serving one deployment each keep their own counter. This
66
- * approximates the provider's limit; it does not enforce it.
67
- */
68
- const NOTION_ADMISSION: ConnectorCallAdmissionPolicy = {
69
- rules: [
70
- {
71
- maxConcurrency: 3,
72
- budget: { kind: "rolling-window", maxCalls: 180, windowMs: 60_000 },
73
- maxQueueSize: 32,
74
- queueTimeoutMs: 5_000,
75
- retryAfterMs: 1_000,
76
- },
77
- ],
78
- };
79
-
80
- export interface NotionOptions {
81
- /** Human-readable display name; defaults to "Notion". */
82
- title?: string;
83
- /** Which workspace this is and what it should be used for. Required. */
84
- purpose: string;
85
- /** Workspace-specific conventions appended to the maintained provider guide. */
86
- instructions?: string;
87
- /** Operator-facing label for the integration token. */
88
- credentialLabel?: string;
89
- /**
90
- * Default `page_size` for list-shaped tools when the caller omits one.
91
- * Defaults to 25; Notion's maximum is 100.
92
- */
93
- defaultPageSize?: number;
94
- /** Connector-specific inline result limit; omit to inherit the deployment. */
95
- maxResultBytes?: number;
96
- }
97
-
98
- // ---------------------------------------------------------------------------
99
- // Transport and typed failures
100
- // ---------------------------------------------------------------------------
101
-
102
- interface NotionRequest {
103
- method: "GET" | "POST" | "PATCH";
104
- path: string;
105
- query?: Record<string, string | number | undefined>;
106
- body?: Record<string, unknown>;
107
- }
108
-
109
- /** `Retry-After` is documented as an integer number of seconds, in decimal. */
110
- function parseRetryAfterMs(header: string | null): number | undefined {
111
- if (!header) return undefined;
112
- const seconds = Number(header.trim());
113
- if (!Number.isFinite(seconds) || seconds < 0) return undefined;
114
- return Math.trunc(seconds * 1000);
115
- }
116
-
117
- /**
118
- * Map one Notion error response onto connecta's typed failures.
119
- *
120
- * The mapping is deliberately not one-to-one. Notion's `code` values describe
121
- * what its API thinks happened; connecta's codes describe what the *caller*
122
- * should do next, and two of Notion's are easy to mistranslate:
123
- *
124
- * - `restricted_resource` (403) is not `auth_required`. The token is fine; the
125
- * integration lacks a capability or was never shared this object. Routing it
126
- * to `auth_required` would send an agent to `authorize_connector`, which
127
- * cannot fix it. It is a non-retryable call failure with instructions.
128
- * - `object_not_found` (404) is overloaded by Notion itself — it means "no
129
- * such object" *or* "not shared with this integration", and the API will not
130
- * say which. The message says both, because treating it as definitive
131
- * absence is how an agent concludes a page was deleted when it simply was
132
- * never shared.
133
- */
134
- function notionFailure(
135
- status: number,
136
- body: Record<string, unknown> | undefined,
137
- retryAfterHeader: string | null,
138
- ): ConnectorCallError {
139
- const code = typeof body?.["code"] === "string" ? body["code"] : undefined;
140
- const detail =
141
- typeof body?.["message"] === "string" && body["message"].trim()
142
- ? body["message"].trim()
143
- : `Notion returned HTTP ${status}.`;
144
- const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
145
- const labelled = code ? `Notion ${code}: ${detail}` : detail;
146
-
147
- if (status === 429) {
148
- const additional = body?.["additional_data"];
149
- const reason =
150
- additional && typeof additional === "object"
151
- ? (additional as Record<string, unknown>)["rate_limit_reason"]
152
- : undefined;
153
- return new ConnectorCallError(
154
- "rate_limited",
155
- `${labelled}${
156
- typeof reason === "string" ? ` (limit: ${reason})` : ""
157
- } Notion allows roughly three requests per second per integration.`,
158
- { retryAfterMs: retryAfterMs ?? 1_000 },
159
- );
160
- }
161
- if (status === 529) {
162
- // Notion documents 529 alongside 429: back off and respect Retry-After.
163
- return new ConnectorCallError(
164
- "unavailable",
165
- `${labelled} Notion is overloaded; retry after the reported window.`,
166
- { retryAfterMs: retryAfterMs ?? 5_000 },
167
- );
168
- }
169
- if (status === 401) {
170
- return new ConnectorCallError(
171
- "auth_required",
172
- `${labelled} The Notion integration token is missing or invalid — an operator must set a valid token on /credentials.`,
173
- );
174
- }
175
- if (status === 403) {
176
- return new ConnectorCallError(
177
- "connector_call_failed",
178
- `${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.`,
179
- { retryable: false },
180
- );
181
- }
182
- if (status === 404) {
183
- return new ConnectorCallError(
184
- "connector_call_failed",
185
- `${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.`,
186
- { retryable: false },
187
- );
188
- }
189
- if (status === 400) {
190
- // Every documented 400 (validation_error, invalid_json, invalid_request,
191
- // invalid_request_url, missing_version, invalid_beta) is a malformed
192
- // request, which is exactly what invalid_args means to the caller.
193
- return new ConnectorCallError("invalid_args", labelled);
194
- }
195
- if (status === 409) {
196
- return new ConnectorCallError(
197
- "unavailable",
198
- `${labelled} Notion reported a write conflict; this is safe to retry.`,
199
- { retryAfterMs: retryAfterMs ?? 1_000 },
200
- );
201
- }
202
- if (status >= 500) {
203
- return new ConnectorCallError(
204
- "unavailable",
205
- `${labelled} Notion is failing upstream.`,
206
- retryAfterMs !== undefined ? { retryAfterMs } : {},
207
- );
208
- }
209
- return new ConnectorCallError("connector_call_failed", labelled, {
210
- retryable: false,
211
- });
212
- }
213
-
214
- async function notionRequest(
215
- ctx: ConnectorContext,
216
- request: NotionRequest,
217
- ): Promise<any> {
218
- const token = (await ctx.credential?.get())?.trim();
219
- if (!token) {
220
- throw new ConnectorCallError(
221
- "auth_required",
222
- "No Notion integration token is configured for this connector — an operator must add one on /credentials before any Notion call can run.",
223
- );
224
- }
225
-
226
- const url = new URL(request.path, NOTION_API_BASE_URL);
227
- for (const [key, value] of Object.entries(request.query ?? {})) {
228
- if (value !== undefined) url.searchParams.set(key, String(value));
229
- }
230
-
231
- const headers: Record<string, string> = {
232
- Authorization: `Bearer ${token}`,
233
- "Notion-Version": NOTION_API_VERSION,
234
- };
235
- if (request.body !== undefined) headers["Content-Type"] = "application/json";
236
-
237
- const response = await fetch(url, {
238
- method: request.method,
239
- headers,
240
- ...(request.body !== undefined
241
- ? { body: JSON.stringify(request.body) }
242
- : {}),
243
- ...(ctx.signal ? { signal: ctx.signal } : {}),
244
- });
245
-
246
- const text = await response.text();
247
- let payload: Record<string, unknown> | undefined;
248
- try {
249
- payload = text ? (JSON.parse(text) as Record<string, unknown>) : undefined;
250
- } catch {
251
- payload = undefined;
252
- }
253
-
254
- if (!response.ok) {
255
- throw notionFailure(
256
- response.status,
257
- payload,
258
- response.headers.get("Retry-After"),
259
- );
260
- }
261
- return payload ?? {};
262
- }
263
-
264
- // ---------------------------------------------------------------------------
265
- // Projections
266
- //
267
- // Notion's payloads are the reason this connection is hand-written. A single
268
- // page carries every property as a discriminated wrapper object, every string
269
- // as an array of rich-text runs each with its own annotations block, and every
270
- // user reference as a nested object. The projections below reduce that to what
271
- // an agent reasons about — ids, plain text, and flattened values — and each
272
- // tool that can lose information this way takes `raw: true` to opt out.
273
- // ---------------------------------------------------------------------------
274
-
275
- /** Concatenate a rich-text array to its plain text. Safe for every variant. */
276
- function plainText(value: unknown): string {
277
- if (!Array.isArray(value)) return "";
278
- return value
279
- .map((run: any) => (typeof run?.plain_text === "string" ? run.plain_text : ""))
280
- .join("");
281
- }
282
-
283
- /** Wrap a plain string as the single-run rich-text array Notion expects. */
284
- function richText(value: string): Array<Record<string, unknown>> {
285
- return [{ type: "text", text: { content: value } }];
286
- }
287
-
288
- function userRef(value: any): Record<string, unknown> | null {
289
- if (!value || typeof value !== "object") return null;
290
- return {
291
- id: typeof value.id === "string" ? value.id : null,
292
- ...(typeof value.name === "string" ? { name: value.name } : {}),
293
- };
294
- }
295
-
296
- /**
297
- * Flatten one Notion property value.
298
- *
299
- * The `default` branch is not laziness: Notion adds property types to every
300
- * API version simultaneously, so an exhaustive switch would start returning
301
- * `undefined` for a type that shipped after this release. Unwrapping
302
- * `value[value.type]` degrades an unknown type to its raw payload instead.
303
- */
304
- function projectPropertyValue(value: any): unknown {
305
- const type = value?.type;
306
- switch (type) {
307
- case "title":
308
- case "rich_text":
309
- return plainText(value[type]);
310
- case "number":
311
- case "checkbox":
312
- case "url":
313
- case "email":
314
- case "phone_number":
315
- case "created_time":
316
- case "last_edited_time":
317
- return value[type] ?? null;
318
- case "select":
319
- case "status":
320
- return value[type]?.name ?? null;
321
- case "multi_select":
322
- return (value.multi_select ?? []).map((option: any) => option?.name ?? null);
323
- case "date":
324
- return value.date
325
- ? {
326
- start: value.date.start ?? null,
327
- end: value.date.end ?? null,
328
- ...(value.date.time_zone ? { time_zone: value.date.time_zone } : {}),
329
- }
330
- : null;
331
- case "people":
332
- return (value.people ?? []).map(userRef);
333
- case "created_by":
334
- case "last_edited_by":
335
- return userRef(value[type]);
336
- case "files":
337
- return (value.files ?? []).map((file: any) => ({
338
- name: file?.name ?? null,
339
- // A `file` upload carries a signed URL that expires; an `external` one
340
- // is a plain link. Agents want the link either way.
341
- url: file?.external?.url ?? file?.file?.url ?? null,
342
- }));
343
- case "relation":
344
- return (value.relation ?? [])
345
- .map((related: any) => related?.id ?? null)
346
- .filter((id: unknown) => typeof id === "string");
347
- case "formula":
348
- return value.formula?.[value.formula?.type] ?? null;
349
- case "rollup": {
350
- const rollup = value.rollup;
351
- if (!rollup) return null;
352
- if (rollup.type === "array") {
353
- return (rollup.array ?? []).map(projectPropertyValue);
354
- }
355
- return rollup[rollup.type] ?? null;
356
- }
357
- case "unique_id":
358
- return value.unique_id?.prefix
359
- ? `${value.unique_id.prefix}-${value.unique_id.number}`
360
- : (value.unique_id?.number ?? null);
361
- case "verification":
362
- return value.verification?.state ?? null;
363
- default:
364
- return type ? (value[type] ?? null) : null;
365
- }
366
- }
367
-
368
- /**
369
- * Unwrap one item from a paginated property-item list.
370
- *
371
- * `GET /v1/pages/{id}/properties/{id}` does *not* return page-shaped values.
372
- * On a page object the type key holds an array — `relation: [{ id }, ...]`.
373
- * In a property-item list each result holds a single object under its type
374
- * key — `{ object: "property_item", type: "relation", relation: { id } }` —
375
- * so feeding these to `projectPropertyValue` would `.map` a non-array and
376
- * throw a raw `TypeError` straight through the typed-failure contract.
377
- *
378
- * Notion paginates exactly four types (`title`, `rich_text`, `relation`,
379
- * `people`); everything else arrives as a single item whose shape already
380
- * matches a page property, so the default branch defers to the shared
381
- * projection and keeps unknown types degrading rather than vanishing.
382
- */
383
- function projectPropertyItem(item: any): unknown {
384
- const type = item?.type;
385
- switch (type) {
386
- case "title":
387
- case "rich_text":
388
- return item[type]?.plain_text ?? "";
389
- case "relation":
390
- return item.relation?.id ?? null;
391
- case "people":
392
- return userRef(item.people);
393
- default:
394
- return projectPropertyValue(item);
395
- }
396
- }
397
-
398
- /** A property Notion truncated, with the id `get_page_property` takes. */
399
- interface TruncatedProperty {
400
- name: string;
401
- id: string | null;
402
- }
403
-
404
- interface ProjectedProperties {
405
- properties: Record<string, unknown>;
406
- /**
407
- * Properties Notion truncated. `GET /v1/pages/{id}` returns at most 25
408
- * entries for each of its four paginated types — `title`, `rich_text`,
409
- * `relation`, and `people` — and says so only with a `has_more` flag on the
410
- * property itself. Surfacing them is what lets an agent know to call
411
- * `get_page_property` instead of quietly reasoning about 25 of 300, and the
412
- * `id` travels with the name because that tool addresses properties by id.
413
- */
414
- truncated: TruncatedProperty[];
415
- }
416
-
417
- function projectProperties(
418
- source: unknown,
419
- select: string[] | undefined,
420
- ): ProjectedProperties {
421
- const properties: Record<string, unknown> = {};
422
- const truncated: TruncatedProperty[] = [];
423
- if (!source || typeof source !== "object") return { properties, truncated };
424
- for (const [name, value] of Object.entries(
425
- source as Record<string, unknown>,
426
- )) {
427
- if (select && !select.includes(name)) continue;
428
- properties[name] = projectPropertyValue(value);
429
- if ((value as any)?.has_more === true) {
430
- truncated.push({
431
- name,
432
- id: typeof (value as any)?.id === "string" ? (value as any).id : null,
433
- });
434
- }
435
- }
436
- return { properties, truncated };
437
- }
438
-
439
- /** The title property's name is arbitrary; its `type` is not. */
440
- function pageTitle(source: unknown): string {
441
- if (!source || typeof source !== "object") return "";
442
- for (const value of Object.values(source as Record<string, any>)) {
443
- if (value?.type === "title") return plainText(value.title);
444
- }
445
- return "";
446
- }
447
-
448
- function parentRef(parent: any): Record<string, unknown> | null {
449
- if (!parent || typeof parent !== "object") return null;
450
- const type = parent.type;
451
- if (typeof type !== "string") return null;
452
- return { type, id: typeof parent[type] === "string" ? parent[type] : null };
453
- }
454
-
455
- function iconRef(icon: any): string | null {
456
- if (!icon || typeof icon !== "object") return null;
457
- if (typeof icon.emoji === "string") return icon.emoji;
458
- return icon.external?.url ?? icon.file?.url ?? null;
459
- }
460
-
461
- function projectPage(page: any, select?: string[]): Record<string, unknown> {
462
- const { properties, truncated } = projectProperties(page?.properties, select);
463
- return {
464
- id: page?.id ?? null,
465
- object: "page",
466
- title: pageTitle(page?.properties),
467
- url: page?.url ?? null,
468
- parent: parentRef(page?.parent),
469
- icon: iconRef(page?.icon),
470
- created_time: page?.created_time ?? null,
471
- last_edited_time: page?.last_edited_time ?? null,
472
- created_by: userRef(page?.created_by),
473
- last_edited_by: userRef(page?.last_edited_by),
474
- in_trash: page?.in_trash === true,
475
- is_archived: page?.is_archived === true,
476
- properties,
477
- ...(truncated.length ? { truncated_properties: truncated } : {}),
478
- };
479
- }
480
-
481
- /**
482
- * Search returns pages and data sources; both get the identity fields only.
483
- *
484
- * Deliberately omits `properties`. A 25-result search over a populated
485
- * database would otherwise return several hundred flattened property values
486
- * for results the agent is about to discard — the exact bloat this connection
487
- * exists to remove. `get_page` fetches properties for the one that matched.
488
- */
489
- function projectSearchHit(hit: any): Record<string, unknown> {
490
- if (hit?.object === "data_source") {
491
- return {
492
- id: hit?.id ?? null,
493
- object: "data_source",
494
- title: plainText(hit?.title) || (hit?.name ?? ""),
495
- database_id: hit?.parent?.database_id ?? null,
496
- url: hit?.url ?? null,
497
- last_edited_time: hit?.last_edited_time ?? null,
498
- };
499
- }
500
- return {
501
- id: hit?.id ?? null,
502
- object: hit?.object ?? "page",
503
- title:
504
- hit?.object === "page" ? pageTitle(hit?.properties) : plainText(hit?.title),
505
- url: hit?.url ?? null,
506
- parent: parentRef(hit?.parent),
507
- last_edited_time: hit?.last_edited_time ?? null,
508
- };
509
- }
510
-
511
- /** Flatten one block to its text plus the few fields its type actually adds. */
512
- function projectBlock(block: any, depth: number): Record<string, unknown> {
513
- const type = block?.type;
514
- const payload = type ? block?.[type] : undefined;
515
- const projected: Record<string, unknown> = {
516
- id: block?.id ?? null,
517
- type: type ?? "unsupported",
518
- depth,
519
- text: plainText(payload?.rich_text),
520
- has_children: block?.has_children === true,
521
- };
522
- switch (type) {
523
- case "to_do":
524
- projected["checked"] = payload?.checked === true;
525
- break;
526
- case "code":
527
- projected["language"] = payload?.language ?? null;
528
- break;
529
- case "child_page":
530
- case "child_database":
531
- // Notion gives these a plain string title, not a rich-text array.
532
- projected["text"] = typeof payload?.title === "string" ? payload.title : "";
533
- break;
534
- case "image":
535
- case "video":
536
- case "file":
537
- case "pdf":
538
- projected["url"] = payload?.external?.url ?? payload?.file?.url ?? null;
539
- projected["text"] = plainText(payload?.caption);
540
- break;
541
- case "bookmark":
542
- case "embed":
543
- case "link_preview":
544
- projected["url"] = payload?.url ?? null;
545
- projected["text"] = plainText(payload?.caption);
546
- break;
547
- case "equation":
548
- projected["text"] = payload?.expression ?? "";
549
- break;
550
- case "table_row":
551
- projected["cells"] = (payload?.cells ?? []).map(plainText);
552
- break;
553
- case "callout":
554
- projected["icon"] = iconRef(payload?.icon);
555
- break;
556
- default:
557
- // Notion adds block types to every API version at once, so an unhandled
558
- // type is expected rather than exceptional — `meeting_notes` shipped in
559
- // the very version pinned above. A payload built around `rich_text` is
560
- // already fully represented by `text`; one that is not would otherwise
561
- // project to an empty string and lose its entire body, so it keeps the
562
- // payload verbatim. `color` alone is presentation, not content.
563
- if (carriesUnprojectedContent(payload)) projected["raw"] = payload;
564
- break;
565
- }
566
- return projected;
567
- }
568
-
569
- function carriesUnprojectedContent(payload: unknown): boolean {
570
- if (!payload || typeof payload !== "object") return false;
571
- if (Array.isArray((payload as any).rich_text)) return false;
572
- return Object.keys(payload as object).some((key) => key !== "color");
573
- }
574
-
575
- function projectUser(user: any): Record<string, unknown> {
576
- return {
577
- id: user?.id ?? null,
578
- name: user?.name ?? null,
579
- type: user?.type ?? null,
580
- ...(user?.person?.email ? { email: user.person.email } : {}),
581
- ...(user?.bot ? { bot: true } : {}),
582
- };
583
- }
584
-
585
- function projectComment(comment: any): Record<string, unknown> {
586
- return {
587
- id: comment?.id ?? null,
588
- discussion_id: comment?.discussion_id ?? null,
589
- created_time: comment?.created_time ?? null,
590
- created_by: userRef(comment?.created_by),
591
- text: plainText(comment?.rich_text),
592
- };
593
- }
594
-
595
- /**
596
- * A data source's schema, reduced to what a caller needs to filter and write.
597
- *
598
- * Select and status options are kept because a filter or a write that invents
599
- * an option name fails; everything else about a property collapses to its type.
600
- */
601
- function projectSchemaProperty(property: any): Record<string, unknown> {
602
- const type = property?.type;
603
- const projected: Record<string, unknown> = {
604
- id: property?.id ?? null,
605
- type: type ?? null,
606
- };
607
- const payload = type ? property?.[type] : undefined;
608
- if (type === "select" || type === "multi_select") {
609
- projected["options"] = (payload?.options ?? []).map(
610
- (option: any) => option?.name ?? null,
611
- );
612
- } else if (type === "status") {
613
- projected["options"] = (payload?.options ?? []).map(
614
- (option: any) => option?.name ?? null,
615
- );
616
- projected["groups"] = (payload?.groups ?? []).map(
617
- (group: any) => group?.name ?? null,
618
- );
619
- } else if (type === "relation") {
620
- // Requests must send data_source_id; responses carry both. Give the caller
621
- // the one it is allowed to write with.
622
- projected["relation_data_source_id"] = payload?.data_source_id ?? null;
623
- } else if (type === "formula") {
624
- projected["expression"] = payload?.expression ?? null;
625
- } else if (type === "rollup") {
626
- projected["rollup"] = {
627
- relation_property_name: payload?.relation_property_name ?? null,
628
- rollup_property_name: payload?.rollup_property_name ?? null,
629
- function: payload?.function ?? null,
630
- };
631
- }
632
- return projected;
633
- }
634
-
635
- // ---------------------------------------------------------------------------
636
- // Shared schema fragments
637
- // ---------------------------------------------------------------------------
638
-
639
- const RAW_PROPERTY: JsonSchema = {
640
- type: "boolean",
641
- description:
642
- "Return Notion's unprojected response instead of the lean projection. Payloads are large — use only when a field the projection drops is genuinely needed.",
643
- };
644
-
645
- const PAGE_SIZE_PROPERTY: JsonSchema = {
646
- type: "integer",
647
- minimum: 1,
648
- maximum: MAX_PAGE_SIZE,
649
- description: `Results per page (1-${MAX_PAGE_SIZE}). Defaults to the connector's configured page size.`,
650
- };
651
-
652
- const START_CURSOR_PROPERTY: JsonSchema = {
653
- type: "string",
654
- description:
655
- "Opaque cursor from a previous response's next_cursor. Pass it back verbatim; never parse or construct one.",
656
- };
657
-
658
- const PROPERTY_SELECT: JsonSchema = {
659
- type: "array",
660
- items: { type: "string" },
661
- description:
662
- "Return only these property names. Omit for all properties. Narrowing here is the cheapest way to keep results small.",
663
- };
664
-
665
- function listOutputSchema(itemSchema: JsonSchema): JsonSchema {
666
- return {
667
- type: "object",
668
- properties: {
669
- results: { type: "array", items: itemSchema },
670
- has_more: {
671
- type: "boolean",
672
- description: "True when another page exists.",
673
- },
674
- next_cursor: {
675
- type: ["string", "null"],
676
- description: "Pass as start_cursor to fetch the next page.",
677
- },
678
- },
679
- required: ["results", "has_more", "next_cursor"],
680
- };
681
- }
682
-
683
- const PAGE_OUTPUT_SCHEMA: JsonSchema = {
684
- type: "object",
685
- description:
686
- "Projected page. With raw: true this is Notion's full page object instead.",
687
- properties: {
688
- id: { type: "string" },
689
- object: { type: "string" },
690
- title: { type: "string", description: "Plain text of the title property." },
691
- url: { type: ["string", "null"] },
692
- parent: {
693
- type: ["object", "null"],
694
- properties: {
695
- type: { type: "string" },
696
- id: { type: ["string", "null"] },
697
- },
698
- required: ["type", "id"],
699
- },
700
- icon: { type: ["string", "null"], description: "Emoji or icon URL." },
701
- created_time: { type: ["string", "null"] },
702
- last_edited_time: { type: ["string", "null"] },
703
- created_by: { type: ["object", "null"] },
704
- last_edited_by: { type: ["object", "null"] },
705
- in_trash: { type: "boolean" },
706
- is_archived: { type: "boolean" },
707
- properties: {
708
- type: "object",
709
- description:
710
- "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.",
711
- },
712
- truncated_properties: {
713
- type: "array",
714
- items: {
715
- type: "object",
716
- properties: {
717
- name: { type: "string" },
718
- id: {
719
- type: ["string", "null"],
720
- description: "Pass as property_id to get_page_property.",
721
- },
722
- },
723
- required: ["name", "id"],
724
- },
725
- description:
726
- "Properties Notion truncated at 25 entries. Each carries the property_id get_page_property needs to read the complete value.",
727
- },
728
- },
729
- required: ["id", "title", "properties"],
730
- };
731
-
732
- const BLOCK_OUTPUT_SCHEMA: JsonSchema = {
733
- type: "object",
734
- properties: {
735
- id: { type: "string" },
736
- type: { type: "string" },
737
- depth: {
738
- type: "integer",
739
- description: "0 for direct children, 1 for their children, and so on.",
740
- },
741
- text: { type: "string", description: "Plain text of the block." },
742
- has_children: { type: "boolean" },
743
- checked: { type: "boolean", description: "to_do blocks only." },
744
- language: { type: ["string", "null"], description: "code blocks only." },
745
- url: { type: ["string", "null"], description: "Media and link blocks." },
746
- cells: {
747
- type: "array",
748
- items: { type: "string" },
749
- description: "table_row blocks only.",
750
- },
751
- icon: { type: ["string", "null"], description: "callout blocks only." },
752
- raw: {
753
- type: "object",
754
- description:
755
- "The block type's untouched payload, present only for types this projection does not model and whose content is not plain rich text.",
756
- },
757
- },
758
- required: ["id", "type", "depth", "text", "has_children"],
759
- };
760
-
761
- const USER_OUTPUT_SCHEMA: JsonSchema = {
762
- type: "object",
763
- properties: {
764
- id: { type: "string" },
765
- name: { type: ["string", "null"] },
766
- type: { type: ["string", "null"], description: '"person" or "bot".' },
767
- email: { type: "string", description: "Person users only." },
768
- bot: { type: "boolean" },
769
- },
770
- required: ["id", "name", "type"],
771
- };
772
-
773
- const COMMENT_OUTPUT_SCHEMA: JsonSchema = {
774
- type: "object",
775
- properties: {
776
- id: { type: "string" },
777
- discussion_id: { type: ["string", "null"] },
778
- created_time: { type: ["string", "null"] },
779
- created_by: { type: ["object", "null"] },
780
- text: { type: "string" },
781
- },
782
- required: ["id", "discussion_id", "text"],
783
- };
784
-
785
- // ---------------------------------------------------------------------------
786
- // Tools
787
- // ---------------------------------------------------------------------------
788
-
789
- function resolvePageSize(
790
- requested: unknown,
791
- fallback: number,
792
- ): number {
793
- if (typeof requested === "number" && Number.isFinite(requested)) {
794
- return Math.min(Math.max(Math.trunc(requested), 1), MAX_PAGE_SIZE);
795
- }
796
- return fallback;
797
- }
798
-
799
- function listEnvelope(
800
- payload: any,
801
- results: unknown[],
802
- ): Record<string, unknown> {
803
- return {
804
- results,
805
- has_more: payload?.has_more === true,
806
- next_cursor: payload?.next_cursor ?? null,
807
- };
808
- }
809
-
810
- /** Exactly-one-of validation, phrased so the agent knows what to send next. */
811
- function requireExactlyOne(
812
- provided: Array<[string, unknown]>,
813
- hint: string,
814
- ): [string, unknown] {
815
- const present = provided.filter(
816
- ([, value]) => value !== undefined && value !== null && value !== "",
817
- );
818
- if (present.length !== 1) {
819
- throw new ConnectorCallError(
820
- "invalid_args",
821
- `Provide exactly one of ${provided
822
- .map(([name]) => name)
823
- .join(", ")}. ${hint}`,
824
- );
825
- }
826
- return present[0] as [string, unknown];
827
- }
828
-
829
- function buildTools(defaultPageSize: number): ApiTool[] {
830
- return [
831
- // ---------------------------------------------------------------- reads
832
- {
833
- name: "search",
834
- description:
835
- "Find pages and data sources by title across everything shared with this integration. Matches titles only, never page content — use query_data_source to filter rows inside a database. Returns identity fields only; call get_page for a match's properties.",
836
- annotations: { readOnlyHint: true },
837
- inputSchema: {
838
- type: "object",
839
- properties: {
840
- query: {
841
- type: "string",
842
- description:
843
- "Title substring to match. Omit to list everything shared with the integration.",
844
- },
845
- object_type: {
846
- type: "string",
847
- enum: ["page", "data_source"],
848
- description:
849
- "Restrict results to pages or to data sources. Omit for both.",
850
- },
851
- sort: {
852
- type: "string",
853
- enum: ["last_edited_desc", "last_edited_asc", "relevance"],
854
- description: "Result ordering. Defaults to Notion's relevance order.",
855
- },
856
- page_size: PAGE_SIZE_PROPERTY,
857
- start_cursor: START_CURSOR_PROPERTY,
858
- raw: RAW_PROPERTY,
859
- },
860
- additionalProperties: false,
861
- },
862
- outputSchema: listOutputSchema({
863
- type: "object",
864
- properties: {
865
- id: { type: "string" },
866
- object: { type: "string", description: '"page" or "data_source".' },
867
- title: { type: "string" },
868
- url: { type: ["string", "null"] },
869
- parent: { type: ["object", "null"] },
870
- database_id: {
871
- type: ["string", "null"],
872
- description: "Data source hits only: the containing database.",
873
- },
874
- last_edited_time: { type: ["string", "null"] },
875
- },
876
- required: ["id", "object", "title"],
877
- }),
878
- handler: async (args, ctx) => {
879
- const body: Record<string, unknown> = {
880
- page_size: resolvePageSize(args.page_size, defaultPageSize),
881
- };
882
- if (args.query) body["query"] = args.query;
883
- if (args.start_cursor) body["start_cursor"] = args.start_cursor;
884
- if (args.object_type) {
885
- body["filter"] = { property: "object", value: args.object_type };
886
- }
887
- if (args.sort === "relevance") {
888
- body["sort"] = { property: "relevance" };
889
- } else if (args.sort) {
890
- body["sort"] = {
891
- timestamp: "last_edited_time",
892
- direction: args.sort === "last_edited_asc" ? "ascending" : "descending",
893
- };
894
- }
895
- const payload = await notionRequest(ctx, {
896
- method: "POST",
897
- path: "/v1/search",
898
- body,
899
- });
900
- if (args.raw) return payload;
901
- return listEnvelope(
902
- payload,
903
- (payload?.results ?? []).map(projectSearchHit),
904
- );
905
- },
906
- },
907
- {
908
- name: "get_page",
909
- description:
910
- "Fetch one page's metadata and flattened property values by id. Returns the page's properties, not its body content — use get_page_content for the blocks.",
911
- annotations: { readOnlyHint: true },
912
- inputSchema: {
913
- type: "object",
914
- properties: {
915
- page_id: {
916
- type: "string",
917
- description: "Notion page id, with or without dashes.",
918
- },
919
- properties: PROPERTY_SELECT,
920
- raw: RAW_PROPERTY,
921
- },
922
- required: ["page_id"],
923
- additionalProperties: false,
924
- },
925
- outputSchema: PAGE_OUTPUT_SCHEMA,
926
- handler: async (args, ctx) => {
927
- const payload = await notionRequest(ctx, {
928
- method: "GET",
929
- path: `/v1/pages/${encodeURIComponent(args.page_id)}`,
930
- });
931
- if (args.raw) return payload;
932
- return projectPage(payload, args.properties);
933
- },
934
- },
935
- {
936
- name: "get_page_content",
937
- description:
938
- "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.",
939
- annotations: { readOnlyHint: true },
940
- inputSchema: {
941
- type: "object",
942
- properties: {
943
- block_id: {
944
- type: "string",
945
- description:
946
- "Page id, or any block id to read that block's children. A page id is a valid block id.",
947
- },
948
- depth: {
949
- type: "integer",
950
- minimum: 0,
951
- maximum: 2,
952
- description:
953
- "How many levels of nested children to follow. 0 (default) returns direct children only. Each level multiplies downstream requests.",
954
- },
955
- page_size: PAGE_SIZE_PROPERTY,
956
- start_cursor: START_CURSOR_PROPERTY,
957
- raw: {
958
- ...RAW_PROPERTY,
959
- 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.`,
960
- },
961
- },
962
- required: ["block_id"],
963
- additionalProperties: false,
964
- },
965
- outputSchema: {
966
- type: "object",
967
- properties: {
968
- results: { type: "array", items: BLOCK_OUTPUT_SCHEMA },
969
- has_more: {
970
- type: "boolean",
971
- description: "True when the top level has another page of blocks.",
972
- },
973
- next_cursor: { type: ["string", "null"] },
974
- truncated: {
975
- type: "boolean",
976
- description:
977
- "True when the nested walk stopped at its request ceiling. Some descendants are missing; re-read a specific block_id to continue.",
978
- },
979
- },
980
- required: ["results", "has_more", "next_cursor", "truncated"],
981
- },
982
- handler: async (args, ctx) => {
983
- const pageSize = resolvePageSize(args.page_size, defaultPageSize);
984
- const top = await notionRequest(ctx, {
985
- method: "GET",
986
- path: `/v1/blocks/${encodeURIComponent(args.block_id)}/children`,
987
- query: {
988
- page_size: pageSize,
989
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
990
- },
991
- });
992
- if (args.raw) return top;
993
-
994
- const maxDepth = typeof args.depth === "number" ? args.depth : 0;
995
- let spent = 1;
996
- let truncated = false;
997
- const results: Array<Record<string, unknown>> = [];
998
-
999
- const walk = async (blocks: any[], depth: number): Promise<void> => {
1000
- for (const block of blocks) {
1001
- results.push(projectBlock(block, depth));
1002
- if (depth >= maxDepth || block?.has_children !== true) continue;
1003
- if (spent >= MAX_CONTENT_REQUESTS) {
1004
- truncated = true;
1005
- continue;
1006
- }
1007
- spent += 1;
1008
- const child = await notionRequest(ctx, {
1009
- method: "GET",
1010
- path: `/v1/blocks/${encodeURIComponent(block.id)}/children`,
1011
- query: { page_size: MAX_PAGE_SIZE },
1012
- });
1013
- // Nested levels take their first page only; a block with more than
1014
- // 100 children is re-read directly rather than paged here.
1015
- if (child?.has_more === true) truncated = true;
1016
- await walk(child?.results ?? [], depth + 1);
1017
- }
1018
- };
1019
-
1020
- await walk(top?.results ?? [], 0);
1021
- return {
1022
- ...listEnvelope(top, results),
1023
- truncated,
1024
- };
1025
- },
1026
- },
1027
- {
1028
- name: "get_page_property",
1029
- description:
1030
- "Fetch one page property completely, paginating past the 25-entry limit that get_page reports in truncated_properties. Use for title, rich_text, relation, and people properties — the four Notion paginates.",
1031
- annotations: { readOnlyHint: true },
1032
- inputSchema: {
1033
- type: "object",
1034
- properties: {
1035
- page_id: { type: "string", description: "Notion page id." },
1036
- property_id: {
1037
- type: "string",
1038
- description:
1039
- "The property's id, from get_data_source_schema or get_page's truncated_properties — not its name.",
1040
- },
1041
- page_size: PAGE_SIZE_PROPERTY,
1042
- start_cursor: START_CURSOR_PROPERTY,
1043
- raw: RAW_PROPERTY,
1044
- },
1045
- required: ["page_id", "property_id"],
1046
- additionalProperties: false,
1047
- },
1048
- outputSchema: {
1049
- type: "object",
1050
- description:
1051
- "Either a single flattened value, or a paginated list of them for a paginated property. With raw: true this is Notion's property-item response instead.",
1052
- properties: {
1053
- type: {
1054
- type: ["string", "null"],
1055
- description:
1056
- "The property's own type, never the \"property_item\" envelope.",
1057
- },
1058
- value: { description: "Flattened value for a single-value property." },
1059
- results: {
1060
- type: "array",
1061
- description: "Flattened values for a paginated property.",
1062
- },
1063
- has_more: { type: "boolean" },
1064
- next_cursor: { type: ["string", "null"] },
1065
- },
1066
- required: ["type"],
1067
- },
1068
- handler: async (args, ctx) => {
1069
- const payload = await notionRequest(ctx, {
1070
- method: "GET",
1071
- path: `/v1/pages/${encodeURIComponent(
1072
- args.page_id,
1073
- )}/properties/${encodeURIComponent(args.property_id)}`,
1074
- query: {
1075
- page_size: resolvePageSize(args.page_size, defaultPageSize),
1076
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
1077
- },
1078
- });
1079
- if (args.raw) return payload;
1080
- if (payload?.object === "list") {
1081
- return {
1082
- // A list envelope's own `type` is the literal "property_item";
1083
- // the property's real type sits one level down.
1084
- type: payload?.property_item?.type ?? payload?.type ?? null,
1085
- ...listEnvelope(
1086
- payload,
1087
- (payload?.results ?? []).map(projectPropertyItem),
1088
- ),
1089
- };
1090
- }
1091
- return {
1092
- type: payload?.type ?? null,
1093
- value: projectPropertyValue(payload),
1094
- };
1095
- },
1096
- },
1097
- {
1098
- name: "get_database",
1099
- description:
1100
- "Fetch a database container and list the data sources inside it. A database id cannot be queried directly — start here to get the data_source_id that query_data_source and get_data_source_schema need.",
1101
- annotations: { readOnlyHint: true },
1102
- inputSchema: {
1103
- type: "object",
1104
- properties: {
1105
- database_id: { type: "string", description: "Notion database id." },
1106
- },
1107
- required: ["database_id"],
1108
- additionalProperties: false,
1109
- },
1110
- outputSchema: {
1111
- type: "object",
1112
- properties: {
1113
- id: { type: "string" },
1114
- title: { type: "string" },
1115
- url: { type: ["string", "null"] },
1116
- parent: { type: ["object", "null"] },
1117
- in_trash: { type: "boolean" },
1118
- is_inline: { type: "boolean" },
1119
- data_sources: {
1120
- type: "array",
1121
- description:
1122
- "The queryable data sources. Most databases have exactly one.",
1123
- items: {
1124
- type: "object",
1125
- properties: { id: { type: "string" }, name: { type: "string" } },
1126
- required: ["id", "name"],
1127
- },
1128
- },
1129
- },
1130
- required: ["id", "title", "data_sources"],
1131
- },
1132
- handler: async (args, ctx) => {
1133
- const payload = await notionRequest(ctx, {
1134
- method: "GET",
1135
- path: `/v1/databases/${encodeURIComponent(args.database_id)}`,
1136
- });
1137
- return {
1138
- id: payload?.id ?? null,
1139
- title: plainText(payload?.title),
1140
- url: payload?.url ?? null,
1141
- parent: parentRef(payload?.parent),
1142
- in_trash: payload?.in_trash === true,
1143
- is_inline: payload?.is_inline === true,
1144
- data_sources: (payload?.data_sources ?? []).map((source: any) => ({
1145
- id: source?.id ?? null,
1146
- name: source?.name ?? null,
1147
- })),
1148
- };
1149
- },
1150
- },
1151
- {
1152
- name: "get_data_source_schema",
1153
- description:
1154
- "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.",
1155
- annotations: { readOnlyHint: true },
1156
- inputSchema: {
1157
- type: "object",
1158
- properties: {
1159
- data_source_id: {
1160
- type: "string",
1161
- description:
1162
- "Data source id from get_database or search, not a database id.",
1163
- },
1164
- raw: RAW_PROPERTY,
1165
- },
1166
- required: ["data_source_id"],
1167
- additionalProperties: false,
1168
- },
1169
- outputSchema: {
1170
- type: "object",
1171
- properties: {
1172
- id: { type: "string" },
1173
- name: { type: "string" },
1174
- database_id: { type: ["string", "null"] },
1175
- title_property: {
1176
- type: ["string", "null"],
1177
- description:
1178
- "Name of the title-typed property. create_page needs this to title a row.",
1179
- },
1180
- properties: {
1181
- type: "object",
1182
- description:
1183
- "Property name to { id, type, options?, relation_data_source_id? }.",
1184
- },
1185
- },
1186
- required: ["id", "name", "properties"],
1187
- },
1188
- handler: async (args, ctx) => {
1189
- const payload = await notionRequest(ctx, {
1190
- method: "GET",
1191
- path: `/v1/data_sources/${encodeURIComponent(args.data_source_id)}`,
1192
- });
1193
- if (args.raw) return payload;
1194
- const properties: Record<string, unknown> = {};
1195
- let titleProperty: string | null = null;
1196
- for (const [name, property] of Object.entries(
1197
- (payload?.properties ?? {}) as Record<string, any>,
1198
- )) {
1199
- properties[name] = projectSchemaProperty(property);
1200
- if (property?.type === "title") titleProperty = name;
1201
- }
1202
- return {
1203
- id: payload?.id ?? null,
1204
- name: plainText(payload?.title) || (payload?.name ?? ""),
1205
- database_id: payload?.parent?.database_id ?? null,
1206
- title_property: titleProperty,
1207
- properties,
1208
- };
1209
- },
1210
- },
1211
- {
1212
- name: "query_data_source",
1213
- description:
1214
- "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.",
1215
- annotations: { readOnlyHint: true },
1216
- inputSchema: {
1217
- type: "object",
1218
- properties: {
1219
- data_source_id: {
1220
- type: "string",
1221
- description: "Data source id from get_database or search.",
1222
- },
1223
- filter: {
1224
- type: "object",
1225
- description:
1226
- 'Notion filter object, passed through unchanged. Single condition: {"property":"Status","status":{"equals":"Done"}}. Compound: {"and":[...]} or {"or":[...]}. Property names must match get_data_source_schema exactly.',
1227
- },
1228
- sorts: {
1229
- type: "array",
1230
- description: "Sort order, applied in sequence.",
1231
- items: {
1232
- type: "object",
1233
- properties: {
1234
- property: {
1235
- type: "string",
1236
- description: "Property name to sort by.",
1237
- },
1238
- timestamp: {
1239
- type: "string",
1240
- enum: ["created_time", "last_edited_time"],
1241
- description: "Sort by a timestamp instead of a property.",
1242
- },
1243
- direction: {
1244
- type: "string",
1245
- enum: ["ascending", "descending"],
1246
- },
1247
- },
1248
- additionalProperties: false,
1249
- },
1250
- },
1251
- properties: PROPERTY_SELECT,
1252
- page_size: PAGE_SIZE_PROPERTY,
1253
- start_cursor: START_CURSOR_PROPERTY,
1254
- raw: RAW_PROPERTY,
1255
- },
1256
- required: ["data_source_id"],
1257
- additionalProperties: false,
1258
- },
1259
- outputSchema: listOutputSchema(PAGE_OUTPUT_SCHEMA),
1260
- handler: async (args, ctx) => {
1261
- const body: Record<string, unknown> = {
1262
- page_size: resolvePageSize(args.page_size, defaultPageSize),
1263
- };
1264
- if (args.filter) body["filter"] = args.filter;
1265
- if (args.sorts) body["sorts"] = args.sorts;
1266
- if (args.start_cursor) body["start_cursor"] = args.start_cursor;
1267
- const payload = await notionRequest(ctx, {
1268
- method: "POST",
1269
- path: `/v1/data_sources/${encodeURIComponent(
1270
- args.data_source_id,
1271
- )}/query`,
1272
- body,
1273
- });
1274
- if (args.raw) return payload;
1275
- return listEnvelope(
1276
- payload,
1277
- (payload?.results ?? []).map((row: any) =>
1278
- projectPage(row, args.properties),
1279
- ),
1280
- );
1281
- },
1282
- },
1283
- {
1284
- name: "list_users",
1285
- description:
1286
- "List workspace users and bots with their ids, for assigning people properties or attributing edits. Requires the integration's user-information capability.",
1287
- annotations: { readOnlyHint: true },
1288
- inputSchema: {
1289
- type: "object",
1290
- properties: {
1291
- page_size: PAGE_SIZE_PROPERTY,
1292
- start_cursor: START_CURSOR_PROPERTY,
1293
- },
1294
- additionalProperties: false,
1295
- },
1296
- outputSchema: listOutputSchema(USER_OUTPUT_SCHEMA),
1297
- handler: async (args, ctx) => {
1298
- const payload = await notionRequest(ctx, {
1299
- method: "GET",
1300
- path: "/v1/users",
1301
- query: {
1302
- page_size: resolvePageSize(args.page_size, defaultPageSize),
1303
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
1304
- },
1305
- });
1306
- return listEnvelope(payload, (payload?.results ?? []).map(projectUser));
1307
- },
1308
- },
1309
- {
1310
- name: "get_self",
1311
- description:
1312
- "Identify the integration this connector authenticates as, and the workspace it is installed in. The cheapest way to confirm the token works before a longer sequence.",
1313
- annotations: { readOnlyHint: true },
1314
- inputSchema: { type: "object", additionalProperties: false },
1315
- outputSchema: {
1316
- type: "object",
1317
- properties: {
1318
- id: { type: "string" },
1319
- name: { type: ["string", "null"] },
1320
- type: { type: ["string", "null"] },
1321
- workspace_name: { type: ["string", "null"] },
1322
- },
1323
- required: ["id", "name"],
1324
- },
1325
- handler: async (_args, ctx) => {
1326
- const payload = await notionRequest(ctx, {
1327
- method: "GET",
1328
- path: "/v1/users/me",
1329
- });
1330
- return {
1331
- ...projectUser(payload),
1332
- workspace_name: payload?.bot?.workspace_name ?? null,
1333
- };
1334
- },
1335
- },
1336
- {
1337
- name: "list_comments",
1338
- description:
1339
- "List unresolved comments on a page or block as plain text with their discussion ids. Requires the integration's read-comment capability, which is off by default.",
1340
- annotations: { readOnlyHint: true },
1341
- inputSchema: {
1342
- type: "object",
1343
- properties: {
1344
- block_id: {
1345
- type: "string",
1346
- description: "Page id or block id to read comments from.",
1347
- },
1348
- page_size: PAGE_SIZE_PROPERTY,
1349
- start_cursor: START_CURSOR_PROPERTY,
1350
- raw: RAW_PROPERTY,
1351
- },
1352
- required: ["block_id"],
1353
- additionalProperties: false,
1354
- },
1355
- outputSchema: listOutputSchema(COMMENT_OUTPUT_SCHEMA),
1356
- handler: async (args, ctx) => {
1357
- const payload = await notionRequest(ctx, {
1358
- method: "GET",
1359
- path: "/v1/comments",
1360
- query: {
1361
- block_id: args.block_id,
1362
- page_size: resolvePageSize(args.page_size, defaultPageSize),
1363
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
1364
- },
1365
- });
1366
- if (args.raw) return payload;
1367
- return listEnvelope(
1368
- payload,
1369
- (payload?.results ?? []).map(projectComment),
1370
- );
1371
- },
1372
- },
1373
-
1374
- // --------------------------------------------------------------- writes
1375
- {
1376
- name: "create_page",
1377
- description:
1378
- "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.",
1379
- annotations: { readOnlyHint: false },
1380
- inputSchema: {
1381
- type: "object",
1382
- properties: {
1383
- parent_page_id: {
1384
- type: "string",
1385
- description: "Create as a child page of this page.",
1386
- },
1387
- parent_data_source_id: {
1388
- type: "string",
1389
- description:
1390
- "Create as a row in this data source. Not a database id.",
1391
- },
1392
- title: { type: "string", description: "Plain-text title." },
1393
- title_property: {
1394
- type: "string",
1395
- description:
1396
- 'Name of the title-typed property, from 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.',
1397
- },
1398
- properties: {
1399
- type: "object",
1400
- description:
1401
- "Additional Notion property values, keyed by property name and in Notion's own wrapped form, e.g. {\"Status\":{\"status\":{\"name\":\"Todo\"}}}. Read get_data_source_schema first.",
1402
- },
1403
- markdown: {
1404
- type: "string",
1405
- description:
1406
- "Page body as Notion-flavored Markdown. Mutually exclusive with children.",
1407
- },
1408
- children: {
1409
- type: "array",
1410
- maxItems: MAX_CHILDREN_PER_REQUEST,
1411
- description:
1412
- "Page body as raw Notion block objects. Mutually exclusive with markdown.",
1413
- items: { type: "object" },
1414
- },
1415
- icon: { type: "string", description: "Emoji to use as the page icon." },
1416
- },
1417
- additionalProperties: false,
1418
- },
1419
- outputSchema: PAGE_OUTPUT_SCHEMA,
1420
- handler: async (args, ctx) => {
1421
- const [parentKey, parentValue] = requireExactlyOne(
1422
- [
1423
- ["parent_page_id", args.parent_page_id],
1424
- ["parent_data_source_id", args.parent_data_source_id],
1425
- ],
1426
- "A page needs exactly one parent, and a data source is addressed by its data_source_id from get_database — never by a database_id.",
1427
- );
1428
- if (args.markdown !== undefined && args.children !== undefined) {
1429
- throw new ConnectorCallError(
1430
- "invalid_args",
1431
- "Provide either markdown or children for the page body, not both.",
1432
- );
1433
- }
1434
-
1435
- const properties: Record<string, unknown> = { ...args.properties };
1436
- if (args.title !== undefined) {
1437
- properties[args.title_property ?? "title"] = {
1438
- title: richText(args.title),
1439
- };
1440
- }
1441
-
1442
- const body: Record<string, unknown> = {
1443
- parent:
1444
- parentKey === "parent_page_id"
1445
- ? { type: "page_id", page_id: parentValue }
1446
- : { type: "data_source_id", data_source_id: parentValue },
1447
- properties,
1448
- };
1449
- if (args.markdown !== undefined) body["markdown"] = args.markdown;
1450
- if (args.children !== undefined) body["children"] = args.children;
1451
- if (args.icon !== undefined) {
1452
- body["icon"] = { type: "emoji", emoji: args.icon };
1453
- }
1454
-
1455
- return projectPage(
1456
- await notionRequest(ctx, {
1457
- method: "POST",
1458
- path: "/v1/pages",
1459
- body,
1460
- }),
1461
- );
1462
- },
1463
- },
1464
- {
1465
- name: "append_blocks",
1466
- description:
1467
- "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.",
1468
- annotations: { readOnlyHint: false },
1469
- inputSchema: {
1470
- type: "object",
1471
- properties: {
1472
- block_id: {
1473
- type: "string",
1474
- description: "Page id or block id to append into.",
1475
- },
1476
- text: {
1477
- type: "array",
1478
- items: { type: "string" },
1479
- maxItems: MAX_CHILDREN_PER_REQUEST,
1480
- description:
1481
- "Plain-text paragraphs, one block each. Mutually exclusive with children.",
1482
- },
1483
- children: {
1484
- type: "array",
1485
- items: { type: "object" },
1486
- maxItems: MAX_CHILDREN_PER_REQUEST,
1487
- description:
1488
- "Raw Notion block objects, for anything paragraphs cannot express. Mutually exclusive with text.",
1489
- },
1490
- position: {
1491
- type: "string",
1492
- enum: ["end", "start", "after_block"],
1493
- description:
1494
- 'Where to insert. Defaults to "end". "after_block" requires after_block_id.',
1495
- },
1496
- after_block_id: {
1497
- type: "string",
1498
- description: 'Insert directly after this block when position is "after_block".',
1499
- },
1500
- },
1501
- required: ["block_id"],
1502
- additionalProperties: false,
1503
- },
1504
- outputSchema: {
1505
- type: "object",
1506
- properties: {
1507
- appended: {
1508
- type: "integer",
1509
- description: "How many blocks were created.",
1510
- },
1511
- results: { type: "array", items: BLOCK_OUTPUT_SCHEMA },
1512
- },
1513
- required: ["appended", "results"],
1514
- },
1515
- handler: async (args, ctx) => {
1516
- const [kind, value] = requireExactlyOne(
1517
- [
1518
- ["text", args.text],
1519
- ["children", args.children],
1520
- ],
1521
- "Use text for plain paragraphs, or children for raw Notion blocks.",
1522
- );
1523
- const children =
1524
- kind === "text"
1525
- ? (value as string[]).map((line) => ({
1526
- object: "block",
1527
- type: "paragraph",
1528
- paragraph: { rich_text: richText(line) },
1529
- }))
1530
- : (value as unknown[]);
1531
- if (children.length === 0) {
1532
- throw new ConnectorCallError(
1533
- "invalid_args",
1534
- "Nothing to append: provide at least one block.",
1535
- );
1536
- }
1537
-
1538
- const body: Record<string, unknown> = { children };
1539
- if (args.position === "after_block") {
1540
- if (!args.after_block_id) {
1541
- throw new ConnectorCallError(
1542
- "invalid_args",
1543
- 'position "after_block" requires after_block_id.',
1544
- );
1545
- }
1546
- body["position"] = {
1547
- type: "after_block",
1548
- after_block: { id: args.after_block_id },
1549
- };
1550
- } else if (args.position) {
1551
- body["position"] = { type: args.position };
1552
- }
1553
-
1554
- const payload = await notionRequest(ctx, {
1555
- method: "PATCH",
1556
- path: `/v1/blocks/${encodeURIComponent(args.block_id)}/children`,
1557
- body,
1558
- });
1559
- const results = (payload?.results ?? []).map((block: any) =>
1560
- projectBlock(block, 0),
1561
- );
1562
- return { appended: results.length, results };
1563
- },
1564
- },
1565
- {
1566
- name: "update_page_properties",
1567
- description:
1568
- "Overwrite property values on an existing page. Every named property is replaced, not merged, so send a multi_select or relation's complete intended value. Cannot move a page and cannot trash one.",
1569
- // Replaces values that already exist: the host should say so out loud.
1570
- annotations: { readOnlyHint: false, destructiveHint: true },
1571
- inputSchema: {
1572
- type: "object",
1573
- properties: {
1574
- page_id: { type: "string", description: "Notion page id." },
1575
- title: {
1576
- type: "string",
1577
- description: "Replacement plain-text title.",
1578
- },
1579
- title_property: {
1580
- type: "string",
1581
- description:
1582
- 'Name of the title-typed property, from get_data_source_schema. Defaults to "title".',
1583
- },
1584
- properties: {
1585
- type: "object",
1586
- description:
1587
- "Notion property values keyed by property name, in Notion's wrapped form. Read get_data_source_schema for names, types, and valid option names.",
1588
- },
1589
- icon: { type: "string", description: "Replacement emoji icon." },
1590
- },
1591
- required: ["page_id"],
1592
- additionalProperties: false,
1593
- },
1594
- outputSchema: PAGE_OUTPUT_SCHEMA,
1595
- handler: async (args, ctx) => {
1596
- const properties: Record<string, unknown> = { ...args.properties };
1597
- if (args.title !== undefined) {
1598
- properties[args.title_property ?? "title"] = {
1599
- title: richText(args.title),
1600
- };
1601
- }
1602
- if (Object.keys(properties).length === 0 && args.icon === undefined) {
1603
- throw new ConnectorCallError(
1604
- "invalid_args",
1605
- "Nothing to update: provide title, properties, or icon.",
1606
- );
1607
- }
1608
- const body: Record<string, unknown> = {};
1609
- if (Object.keys(properties).length > 0) body["properties"] = properties;
1610
- if (args.icon !== undefined) {
1611
- body["icon"] = { type: "emoji", emoji: args.icon };
1612
- }
1613
- return projectPage(
1614
- await notionRequest(ctx, {
1615
- method: "PATCH",
1616
- path: `/v1/pages/${encodeURIComponent(args.page_id)}`,
1617
- body,
1618
- }),
1619
- );
1620
- },
1621
- },
1622
- {
1623
- name: "trash_page",
1624
- description:
1625
- "Move a page to the workspace trash, or restore one from it. Trashing hides the page and its content from reads; it is reversible through this same tool with restore: true.",
1626
- annotations: { readOnlyHint: false, destructiveHint: true },
1627
- inputSchema: {
1628
- type: "object",
1629
- properties: {
1630
- page_id: { type: "string", description: "Notion page id." },
1631
- restore: {
1632
- type: "boolean",
1633
- description:
1634
- "Restore the page out of the trash instead of moving it in.",
1635
- },
1636
- },
1637
- required: ["page_id"],
1638
- additionalProperties: false,
1639
- },
1640
- outputSchema: {
1641
- type: "object",
1642
- properties: {
1643
- id: { type: "string" },
1644
- title: { type: "string" },
1645
- in_trash: { type: "boolean" },
1646
- },
1647
- required: ["id", "in_trash"],
1648
- },
1649
- handler: async (args, ctx) => {
1650
- const payload = await notionRequest(ctx, {
1651
- method: "PATCH",
1652
- path: `/v1/pages/${encodeURIComponent(args.page_id)}`,
1653
- body: { in_trash: args.restore !== true },
1654
- });
1655
- return {
1656
- id: payload?.id ?? null,
1657
- title: pageTitle(payload?.properties),
1658
- in_trash: payload?.in_trash === true,
1659
- };
1660
- },
1661
- },
1662
- {
1663
- name: "add_comment",
1664
- description:
1665
- "Start a comment discussion on a page, or reply to an existing discussion. Requires the integration's insert-comment capability, which is off by default.",
1666
- annotations: { readOnlyHint: false },
1667
- inputSchema: {
1668
- type: "object",
1669
- properties: {
1670
- page_id: {
1671
- type: "string",
1672
- description: "Start a new discussion on this page.",
1673
- },
1674
- discussion_id: {
1675
- type: "string",
1676
- description:
1677
- "Reply to this existing discussion, from list_comments.",
1678
- },
1679
- text: { type: "string", description: "Comment body as plain text." },
1680
- },
1681
- required: ["text"],
1682
- additionalProperties: false,
1683
- },
1684
- outputSchema: COMMENT_OUTPUT_SCHEMA,
1685
- handler: async (args, ctx) => {
1686
- const [kind, value] = requireExactlyOne(
1687
- [
1688
- ["page_id", args.page_id],
1689
- ["discussion_id", args.discussion_id],
1690
- ],
1691
- "Comment on a page to start a discussion, or name a discussion_id to reply to one.",
1692
- );
1693
- if (!String(args.text).trim()) {
1694
- throw new ConnectorCallError(
1695
- "invalid_args",
1696
- "A comment needs non-empty text.",
1697
- );
1698
- }
1699
- const body: Record<string, unknown> = {
1700
- rich_text: richText(args.text),
1701
- ...(kind === "page_id"
1702
- ? { parent: { type: "page_id", page_id: value } }
1703
- : { discussion_id: value }),
1704
- };
1705
- return projectComment(
1706
- await notionRequest(ctx, {
1707
- method: "POST",
1708
- path: "/v1/comments",
1709
- body,
1710
- }),
1711
- );
1712
- },
1713
- },
1714
- ];
1715
- }
1716
-
1717
- // ---------------------------------------------------------------------------
1718
- // Guide and constructor
1719
- // ---------------------------------------------------------------------------
1720
-
1721
- /**
1722
- * Only what the schemas cannot carry.
1723
- *
1724
- * Marked `required` because the database/data-source split is a mandatory
1725
- * cross-tool sequence, not advice: a caller who reaches for `query_data_source`
1726
- * with the id in a Notion URL gets a failure no schema warned it about.
1727
- */
1728
- function usageGuide(purpose: string, instructions: string | undefined): string {
1729
- const accountInstructions = instructions?.trim();
1730
- return `# Notion usage
1731
-
1732
- Workspace purpose: ${purpose}
1733
-
1734
- ## Databases contain data sources
1735
-
1736
- A Notion database is a container; the rows and the schema live in a *data
1737
- source* inside it. The two ids are not interchangeable and Notion rejects the
1738
- wrong one.
1739
-
1740
- - The id in a Notion database URL is a **database id**.
1741
- - \`get_database\` turns it into the \`data_sources\` list — usually one entry.
1742
- - \`get_data_source_schema\` and \`query_data_source\` take that
1743
- **data_source_id**, and so does \`create_page\` when adding a row.
1744
-
1745
- So the sequence for "find rows in this database" is \`get_database\` →
1746
- \`get_data_source_schema\` → \`query_data_source\`. \`search\` returns data
1747
- sources directly and skips the first step.
1748
-
1749
- ## Property quirks that break writes
1750
-
1751
- - Property names in filters, sorts, and writes must match the schema exactly,
1752
- including case. Read \`get_data_source_schema\` before composing one.
1753
- - \`select\` and \`status\` writes must use an existing option name; inventing
1754
- one fails. The schema lists the valid options.
1755
- - Writes **replace** a property. Sending one item to a \`multi_select\` or
1756
- \`relation\` drops the others, so send the complete intended value.
1757
- - \`rollup\`, \`formula\`, \`unique_id\`, and the created/edited fields are
1758
- computed. They cannot be written.
1759
- - A page's title column is rarely called "title" — pass \`title_property\` from
1760
- the schema when creating or updating a row.
1761
- - Notion truncates \`title\`, \`rich_text\`, \`relation\`, and \`people\` at 25
1762
- entries. \`get_page\` reports each one in \`truncated_properties\` as
1763
- \`{ name, id }\`; pass that \`id\` as \`property_id\` to
1764
- \`get_page_property\` for the complete value.
1765
-
1766
- ## Reading page content
1767
-
1768
- \`get_page\` returns properties. \`get_page_content\` returns the body as flat
1769
- blocks. Nested blocks (toggles, list children, table rows) need \`depth\`, and
1770
- each level multiplies requests — a deep read stops at an internal ceiling and
1771
- reports \`truncated: true\` rather than spending the whole rate-limit budget.
1772
-
1773
- ## Appending is append-only
1774
-
1775
- \`append_blocks\` adds children and nothing else. It cannot move, reorder, or
1776
- replace an existing block, and a block appended through the API can never be
1777
- relocated by it afterwards. Get the position right the first time with
1778
- \`position\` and \`after_block_id\`. Notion caps one call at 100 blocks.
1779
-
1780
- ## Lean by default, raw on request
1781
-
1782
- Every read projects Notion's payload down to ids, plain text, and flattened
1783
- property values. Where the dropped detail can matter — \`search\`, \`get_page\`,
1784
- \`get_page_content\`, \`get_page_property\`, \`get_data_source_schema\`,
1785
- \`query_data_source\`, \`list_comments\` — pass \`raw: true\` to get Notion's
1786
- untouched response instead. It is much larger; reach for it only when a
1787
- specific field is missing. Narrow \`query_data_source\` and \`get_page\` with
1788
- \`properties\` instead whenever the goal is fewer fields, not more.
1789
-
1790
- \`get_page_content\` with \`raw: true\` returns one level exactly as Notion
1791
- sent it and does not walk nested children, so \`depth\` is ignored alongside
1792
- it. A block type this projection does not model keeps its payload under
1793
- \`raw\` on the block itself, so nothing silently flattens to an empty string.
1794
-
1795
- ## Failures worth reading carefully
1796
-
1797
- - **404** means "no such object" *or* "not shared with this integration", and
1798
- Notion will not say which. Never conclude a page was deleted from it; check
1799
- that the page is shared with the integration in Notion.
1800
- - **403** is not an expired token. The integration is missing a capability
1801
- (comment capabilities are off by default) or the object was never shared.
1802
- Re-authorizing cannot fix it; an operator must change it in Notion.
1803
- - **429** carries a retry window. Notion allows roughly three requests per
1804
- second per integration, so wait it out rather than retrying immediately.
1805
-
1806
- ## Writes and pagination
1807
-
1808
- - Notion has **no idempotency key**. A retried \`create_page\` or
1809
- \`add_comment\` creates a duplicate. Confirm with \`search\` before repeating
1810
- a write that may have partially succeeded.
1811
- - List tools take \`page_size\` (max 100) and return \`has_more\` with
1812
- \`next_cursor\`. Pass a cursor back verbatim — it is opaque and must never be
1813
- parsed or constructed. Follow pages inside \`execute_code\` and reduce there.
1814
- ${
1815
- accountInstructions
1816
- ? `\n## Workspace instructions\n\n${accountInstructions}\n`
1817
- : ""
1818
- }`;
1819
- }
1820
-
1821
- /** A maintained Notion connection over the public REST API. */
1822
- export function notion(id: string, options: NotionOptions): Connector {
1823
- const purpose = options.purpose.trim();
1824
- if (!purpose) {
1825
- throw new Error("notion() requires a non-empty workspace purpose.");
1826
- }
1827
- const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
1828
- if (
1829
- !Number.isInteger(defaultPageSize) ||
1830
- defaultPageSize < 1 ||
1831
- defaultPageSize > MAX_PAGE_SIZE
1832
- ) {
1833
- throw new Error(
1834
- `notion() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`,
1835
- );
1836
- }
1837
-
1838
- return api(id, {
1839
- title: options.title ?? "Notion",
1840
- description: `Notion workspace — ${purpose}`,
1841
- credential: {
1842
- label: options.credentialLabel ?? "Notion integration token",
1843
- description:
1844
- "Internal integration token from notion.so/profile/integrations. Every page or database the agent should reach must also be shared with that integration, and its capabilities decide which tools succeed — comment capabilities are off by default.",
1845
- placeholder: "Paste the integration token",
1846
- },
1847
- testCredential: async (value, ctx) => {
1848
- // Notion has no token-introspection endpoint; identifying the bot is the
1849
- // cheapest call that proves the token is live.
1850
- try {
1851
- const payload = await notionRequest(
1852
- { ...ctx, credential: { get: async () => value, getAll: async () => ({ value }) } },
1853
- { method: "GET", path: "/v1/users/me" },
1854
- );
1855
- const name = payload?.bot?.workspace_name ?? payload?.name ?? "Notion";
1856
- return { ok: true, message: `Authenticated as ${name}.` };
1857
- } catch (error) {
1858
- return {
1859
- ok: false,
1860
- message:
1861
- error instanceof ConnectorCallError
1862
- ? error.message
1863
- : "Notion rejected the token.",
1864
- };
1865
- }
1866
- },
1867
- callAdmission: NOTION_ADMISSION,
1868
- usageGuide: {
1869
- content: usageGuide(purpose, options.instructions),
1870
- summary:
1871
- "Database-to-data-source lookup, property write rules, lean-vs-raw results, and Notion's overloaded 403/404.",
1872
- required: true,
1873
- },
1874
- tools: buildTools(defaultPageSize),
1875
- ...(options.maxResultBytes !== undefined
1876
- ? { maxResultBytes: options.maxResultBytes }
1877
- : {}),
1878
- });
1879
- }