@zackbart/connecta 0.31.0 → 0.33.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 (169) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +30 -20
  3. package/bin/connecta.mjs +25 -6
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.d.ts +167 -23
  6. package/dist/access-tokens.js +676 -140
  7. package/dist/agent-identity.js +47 -0
  8. package/dist/auth/cloudflare-access.js +13 -5
  9. package/dist/auth-elicitation.js +1 -1
  10. package/dist/call-admission-context.js +15 -0
  11. package/dist/call-admission.js +30 -43
  12. package/dist/call-recovery.js +6 -2
  13. package/dist/catalog-service.js +7 -2
  14. package/dist/config-value-sources.js +14 -9
  15. package/dist/config.d.ts +58 -78
  16. package/dist/config.js +143 -47
  17. package/dist/connector-access.d.ts +4 -33
  18. package/dist/connector-access.js +15 -99
  19. package/dist/connector-caller.d.ts +7 -1
  20. package/dist/connector-caller.js +3 -2
  21. package/dist/connectors/api-connector.d.ts +27 -4
  22. package/dist/connectors/api-connector.js +2 -0
  23. package/dist/connectors/guarded-fetch.d.ts +9 -0
  24. package/dist/connectors/guarded-fetch.js +7 -0
  25. package/dist/connectors/remote-mcp.js +2 -0
  26. package/dist/describe-config.d.ts +32 -7
  27. package/dist/describe-config.js +9 -18
  28. package/dist/errors.d.ts +1 -1
  29. package/dist/errors.js +1 -0
  30. package/dist/execute.js +16 -14
  31. package/dist/index.d.ts +4 -3
  32. package/dist/index.js +30 -10
  33. package/dist/invocation.js +12 -7
  34. package/dist/membership.d.ts +51 -0
  35. package/dist/membership.js +121 -0
  36. package/dist/meta-tools.js +17 -19
  37. package/dist/module-contracts.d.ts +23 -6
  38. package/dist/operator-record.d.ts +2 -2
  39. package/dist/operator-record.js +2 -2
  40. package/dist/operator-ui/agents.d.ts +74 -0
  41. package/dist/operator-ui/agents.js +104 -0
  42. package/dist/operator-ui/contract.d.ts +151 -29
  43. package/dist/operator-ui/endpoints.js +192 -0
  44. package/dist/operator-ui/fix-prompts.js +4 -4
  45. package/dist/operator-ui/generated.js +5 -4
  46. package/dist/operator-ui/member-page.js +112 -0
  47. package/dist/operator-ui/model.d.ts +15 -7
  48. package/dist/operator-ui/view.d.ts +84 -19
  49. package/dist/operator-ui/view.js +83 -21
  50. package/dist/page-styles.js +1 -1
  51. package/dist/program-writes.js +2 -2
  52. package/dist/provider.d.ts +63 -11
  53. package/dist/provider.js +46 -7
  54. package/dist/providers/_shared/google/workspace.d.ts +53 -16
  55. package/dist/providers/_shared/google/workspace.js +31 -9
  56. package/dist/providers/_shared/rest/tools.js +2 -2
  57. package/dist/providers/_shared/rest/value-safety.d.ts +50 -0
  58. package/dist/providers/_shared/rest/value-safety.js +699 -7
  59. package/dist/providers/breeze/skill.generated.js +2 -2
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +3 -3
  62. package/dist/providers/cloudflare/rest.js +25 -3
  63. package/dist/providers/cloudflare/skill.generated.js +2 -2
  64. package/dist/providers/cloudflare/value-safety.js +32 -0
  65. package/dist/providers/docs/index.d.ts +1 -1
  66. package/dist/providers/docs/index.js +5 -5
  67. package/dist/providers/docs/skill.generated.js +2 -2
  68. package/dist/providers/drive/index.d.ts +1 -1
  69. package/dist/providers/drive/index.js +4 -4
  70. package/dist/providers/drive/skill.generated.js +2 -2
  71. package/dist/providers/forms/index.d.ts +1 -1
  72. package/dist/providers/forms/index.js +5 -5
  73. package/dist/providers/forms/skill.generated.js +2 -2
  74. package/dist/providers/github/skill.generated.js +2 -2
  75. package/dist/providers/gmail/index.d.ts +1 -1
  76. package/dist/providers/gmail/index.js +279 -26
  77. package/dist/providers/gmail/skill.generated.js +2 -2
  78. package/dist/providers/linear/index.js +1 -1
  79. package/dist/providers/mixpanel/index.d.ts +27 -12
  80. package/dist/providers/mixpanel/index.js +58 -6
  81. package/dist/providers/mixpanel/operations.js +20 -0
  82. package/dist/providers/mixpanel/rest.js +603 -0
  83. package/dist/providers/mixpanel/skill.generated.js +3 -2
  84. package/dist/providers/mixpanel/value-safety.js +16 -0
  85. package/dist/providers/notion/index.d.ts +7 -0
  86. package/dist/providers/notion/index.js +116 -11
  87. package/dist/providers/notion/rest.js +183 -24
  88. package/dist/providers/notion/skill.generated.js +4 -4
  89. package/dist/providers/notion/value-safety.js +30 -5
  90. package/dist/providers/overflow/index.js +1 -1
  91. package/dist/providers/planning-center/index.d.ts +27 -0
  92. package/dist/providers/planning-center/index.js +287 -25
  93. package/dist/providers/planning-center/skill.generated.js +24 -4
  94. package/dist/providers/sheets/index.d.ts +4 -1
  95. package/dist/providers/sheets/index.js +9 -6
  96. package/dist/providers/sheets/skill.generated.js +2 -2
  97. package/dist/providers/slides/index.d.ts +1 -1
  98. package/dist/providers/slides/index.js +6 -6
  99. package/dist/providers/slides/skill.generated.js +2 -2
  100. package/dist/providers/stripe/rest.js +9 -2
  101. package/dist/providers/stripe/skill.generated.js +2 -2
  102. package/dist/providers/stripe/value-safety.js +22 -0
  103. package/dist/providers/vercel/index.js +2 -2
  104. package/dist/providers/vercel/rest.js +20 -2
  105. package/dist/providers/vercel/skill.generated.js +2 -2
  106. package/dist/providers/vercel/value-safety.js +22 -0
  107. package/dist/providers/wispr-flow/index.d.ts +14 -0
  108. package/dist/providers/wispr-flow/index.js +117 -0
  109. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  110. package/dist/registry.d.ts +2 -3
  111. package/dist/registry.js +8 -9
  112. package/dist/routes/access-tokens.js +82 -75
  113. package/dist/routes/activity-disclosure.js +17 -18
  114. package/dist/routes/activity.js +34 -54
  115. package/dist/routes/actor-labels.js +33 -4
  116. package/dist/routes/agent-path.js +43 -0
  117. package/dist/routes/connect.js +16 -12
  118. package/dist/routes/credentials.js +16 -40
  119. package/dist/routes/mcp.js +155 -85
  120. package/dist/routes/oauth-management.js +11 -5
  121. package/dist/routes/oauth.js +3 -9
  122. package/dist/routes/operator.js +25 -17
  123. package/dist/routes/shared.d.ts +60 -20
  124. package/dist/routes/shared.js +187 -96
  125. package/dist/routes/ui-config.js +437 -97
  126. package/dist/routes/ui-data.js +2 -2
  127. package/dist/routes/ui.js +9 -16
  128. package/dist/runtime/call-admission.js +9 -0
  129. package/dist/server.js +8 -0
  130. package/dist/skills.js +5 -4
  131. package/dist/storage/keys.js +9 -1
  132. package/dist/tool-safety.js +13 -5
  133. package/dist/types.d.ts +12 -1
  134. package/dist/ui.d.ts +2 -2
  135. package/dist/ui.js +17 -15
  136. package/dist/usage-guide.js +3 -3
  137. package/dist/version.d.ts +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/workspace-address.js +9 -0
  140. package/dist/workspace-subject.js +23 -0
  141. package/documentation/README.md +46 -22
  142. package/documentation/architecture.md +378 -58
  143. package/documentation/auth.md +806 -215
  144. package/documentation/code-mode.md +93 -81
  145. package/documentation/deploying.md +128 -26
  146. package/documentation/integrating.md +145 -22
  147. package/documentation/meta-tools.md +58 -46
  148. package/documentation/migration-0.33.md +344 -0
  149. package/documentation/operating.md +14 -9
  150. package/documentation/operator-tests.md +117 -31
  151. package/documentation/operator-ui.md +326 -55
  152. package/documentation/provider-auth-migration.md +42 -27
  153. package/documentation/provider-migration-0.29.md +3 -3
  154. package/examples/worker/AGENTS.md +35 -8
  155. package/examples/worker/README.md +92 -53
  156. package/examples/worker/src/connecta.config.ts +41 -20
  157. package/examples/worker/wrangler.jsonc +3 -0
  158. package/package.json +7 -2
  159. package/templates/node/.env.example +3 -0
  160. package/templates/node/AGENTS.md +33 -10
  161. package/templates/node/Dockerfile +2 -2
  162. package/templates/node/README.md +71 -44
  163. package/templates/node/docker-compose.yml +3 -2
  164. package/templates/node/package.json +4 -3
  165. package/templates/node/src/adopt-token.ts +23 -0
  166. package/templates/node/src/connecta.config.ts +33 -12
  167. package/templates/node/src/rotate-token.ts +17 -0
  168. package/dist/tool-safety.d.ts +0 -9
  169. package/templates/node/src/provision-token.ts +0 -17
@@ -2,9 +2,9 @@ import { retryAfterMs } from "../../connectors/guarded-fetch.js";
2
2
  import { ConnectorCallError } from "../../errors.js";
3
3
  import { OperationIndex } from "../_shared/rest/operation-index.js";
4
4
  import { callRest, restCall, restTools, restTransport, } from "../_shared/rest/tools.js";
5
- import { valueSafety } from "../_shared/rest/value-safety.js";
5
+ import { carriesWithheldLink, valueSafety } from "../_shared/rest/value-safety.js";
6
6
  import { openapi } from "./openapi.generated.js";
7
- import { NOTION_VALUE_SAFETY } from "./value-safety.js";
7
+ import { NOTION_FILE_HOSTS, NOTION_VALUE_SAFETY, sanitizeLinks } from "./value-safety.js";
8
8
  /** Notion's REST origin. Every request this connector sends goes to exactly this host. */
9
9
  export const NOTION_API_BASE_URL = "https://api.notion.com";
10
10
  /**
@@ -21,8 +21,13 @@ export const NOTION_API_BASE_URL = "https://api.notion.com";
21
21
  export const NOTION_API_VERSION = "2026-03-11";
22
22
  /** Notion's hard cap on `page_size` for every paginated endpoint. */
23
23
  export const MAX_PAGE_SIZE = 100;
24
+ /** Page retrieval caps `filter_properties` at 100; data-source queries declare no cap. */
25
+ const MAX_PAGE_PROPERTY_FILTERS = 100;
24
26
  /** Notion's cap on `children` per append, and on blocks per page create. */
25
27
  const MAX_CHILDREN_PER_REQUEST = 100;
28
+ /** Notion's cap on one rich-text run's `text.content`, and on runs in one rich-text array. */
29
+ const MAX_TEXT_CONTENT = 2_000;
30
+ const MAX_RICH_TEXT_RUNS = 100;
26
31
  /**
27
32
  * Ceiling on the fetches one `integration_get_page_content` walk may spend. Call admission
28
33
  * meters tool calls, not the requests inside them, so a deep `depth` would
@@ -61,21 +66,78 @@ function isRecord(value) {
61
66
  function invalid(message) {
62
67
  throw new ConnectorCallError("invalid_args", message);
63
68
  }
69
+ /**
70
+ * Notion repeats `Retry-After` in the body as `additional_data.retry_after`,
71
+ * a string of whole seconds, so a proxy that drops the header still leaves a
72
+ * window to wait out.
73
+ */
74
+ function bodyRetryAfterMs(additional) {
75
+ const raw = isRecord(additional) ? additional["retry_after"] : undefined;
76
+ const seconds = typeof raw === "string" || typeof raw === "number" ? Number(raw) : Number.NaN;
77
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.trunc(seconds * 1000) : undefined;
78
+ }
79
+ /** A Notion object id, dashed or not: the only committed-id shape shown when error text is withheld. */
80
+ const NOTION_ID = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
81
+ /**
82
+ * The ids a 503 reports as already committed, so a caller reads them instead
83
+ * of repeating the write. For an operation whose error text is withheld, only
84
+ * ids shaped as Notion ids are named, since anything else could echo what the
85
+ * request sent; the write is still reported as committed.
86
+ */
87
+ function committedIds(additional, withheld) {
88
+ if (!isRecord(additional))
89
+ return "";
90
+ const resource = additional["committed_resource_id"];
91
+ const children = Array.isArray(additional["committed_child_ids"])
92
+ ? additional["committed_child_ids"].filter((child) => typeof child === "string")
93
+ : [];
94
+ if (typeof resource !== "string" || resource === "")
95
+ return "";
96
+ if (withheld && ![resource, ...children].every((committed) => NOTION_ID.test(committed))) {
97
+ return " Notion committed the write before failing; what it committed is withheld because this operation handles credentials. Read the object instead of repeating the write.";
98
+ }
99
+ return children.length
100
+ ? ` Notion committed the write before failing: it appended ${children.length} block(s) (${children.join(", ")}) under ${resource}. Read that block instead of repeating the write.`
101
+ : ` Notion committed the write before failing: the object it created or changed is ${resource}. Read it instead of repeating the write.`;
102
+ }
103
+ /**
104
+ * Notion's own next step for a failed write, `additional_data.retry_guidance`.
105
+ * An update to an existing object that was saved anyway names no committed
106
+ * id, only this, so it is relayed as Notion wrote it.
107
+ */
108
+ function retryGuidance(additional) {
109
+ const guidance = isRecord(additional) ? additional["retry_guidance"] : undefined;
110
+ const lines = (Array.isArray(guidance) ? guidance : [guidance]).filter((line) => typeof line === "string" && line.trim() !== "");
111
+ return lines.map((line) => line.trim()).join(" ");
112
+ }
64
113
  /**
65
114
  * Map to what the caller should do next, not to what Notion's `code` says
66
115
  * happened. The two that are easy to mistranslate:
67
116
  *
68
117
  * - **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.
118
+ * lacks a capability, was never shared the object, or the workspace is past
119
+ * its block limit, and neither `authorize_connector` nor a new token can fix
120
+ * it. Notion's message says which. Non-retryable call failure.
71
121
  * - **404 does not prove absence.** Notion returns `object_not_found` both for an
72
122
  * object that does not exist and for one never shared with the integration, and
73
123
  * will not say which — so not `not_found`, which exists to assert absence
74
124
  * (H11). The message names both possibilities.
125
+ *
126
+ * A 503 on a write may still have been saved. When Notion names what it
127
+ * committed, or gives its own retry guidance, the failure says so and stops
128
+ * being retryable; otherwise the shared path reports a write's outcome as
129
+ * unknown (INV-9).
130
+ *
131
+ * `withheld` replaces every free-text field (message, retry guidance,
132
+ * committed ids) for an operation whose error text the review withholds.
133
+ * Text that does pass has had its pre-signed file links withheld first
134
+ * (`sanitizeLinks`, as on success), since a message can echo the Markdown it
135
+ * rejected.
75
136
  */
76
137
  function notionFailure(status, headers, payload, withheld = false) {
77
138
  const body = isRecord(payload) ? payload : undefined;
78
139
  const code = typeof body?.["code"] === "string" ? body["code"] : undefined;
140
+ const additional = body?.["additional_data"];
79
141
  // For an operation the value-safety review withholds, Notion's message is
80
142
  // replaced; its code still routes.
81
143
  const detail = withheld
@@ -83,12 +145,17 @@ function notionFailure(status, headers, payload, withheld = false) {
83
145
  : typeof body?.["message"] === "string" && body["message"].trim()
84
146
  ? body["message"].trim()
85
147
  : `Notion returned HTTP ${status}.`;
86
- const retryAfter = retryAfterMs(headers);
148
+ const retryAfter = retryAfterMs(headers) ?? bodyRetryAfterMs(additional);
87
149
  const labelled = code ? `Notion ${code}: ${detail}` : detail;
88
150
  if (status === 429) {
89
- const additional = body?.["additional_data"];
90
151
  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 });
152
+ if (reason === "public_api_request_blocked") {
153
+ return new ConnectorCallError("connector_call_failed", `${labelled} (limit: ${reason}) Notion has restricted this integration's API access. Retrying will not help; an operator must contact Notion support.`, { retryable: false });
154
+ }
155
+ const scope = reason === "public_api_space_request_rate_limit"
156
+ ? " The workspace's shared budget across all its connections is spent, and its window can exceed a minute."
157
+ : " Notion allows each integration 180 requests a minute (600 on Business and Enterprise), and a separate budget shared by the whole workspace.";
158
+ return new ConnectorCallError("rate_limited", `${labelled}${typeof reason === "string" ? ` (limit: ${reason})` : ""}${scope} Wait out the retry window.`, { retryAfterMs: retryAfter ?? 1_000 });
92
159
  }
93
160
  if (status === 529) {
94
161
  // Notion documents 529 alongside 429: back off and respect Retry-After.
@@ -100,7 +167,7 @@ function notionFailure(status, headers, payload, withheld = false) {
100
167
  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
168
  }
102
169
  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 });
170
+ return new ConnectorCallError("provider_permission_denied", `${labelled} The token is valid but Notion refused this operation, and Notion's message says why. Usually an operator must enable the matching capability on the Notion integration (comment capabilities are off by default) or share the object with it; a workspace past its plan's block limit also answers 403. Re-authorizing will not help.`, { retryable: false });
104
171
  }
105
172
  if (status === 404) {
106
173
  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 });
@@ -117,6 +184,16 @@ function notionFailure(status, headers, payload, withheld = false) {
117
184
  });
118
185
  }
119
186
  if (status >= 500) {
187
+ const committed = committedIds(additional, withheld);
188
+ if (committed) {
189
+ return new ConnectorCallError("connector_call_failed", `${labelled}${committed}`, { retryable: false });
190
+ }
191
+ const guidance = retryGuidance(additional);
192
+ if (guidance) {
193
+ return new ConnectorCallError("connector_call_failed", withheld
194
+ ? `${labelled} Notion sent retry guidance, withheld because this operation handles credentials. Read the object before repeating the write.`
195
+ : `${labelled} Notion's guidance: ${guidance}`, { retryable: false });
196
+ }
120
197
  return new ConnectorCallError("unavailable", `${labelled} Notion is failing upstream.`, retryAfter !== undefined ? { retryAfterMs: retryAfter } : {});
121
198
  }
122
199
  return new ConnectorCallError("connector_call_failed", labelled, { retryable: false });
@@ -131,6 +208,9 @@ function notionFailure(status, headers, payload, withheld = false) {
131
208
  * (until `expires_at`), so the page names that path.
132
209
  * - `POST /v1/blocks/meeting_notes/query` has no cursor at all: `has_more`
133
210
  * past its `limit` (50 at most) can only be answered by a narrower filter.
211
+ * - A data source or view query stops at 10,000 results with `has_more: false`
212
+ * and `request_status.type: "incomplete"`. That is a cut-off with no cursor,
213
+ * reported like the meeting-note one: `hasMore` without `next`.
134
214
  *
135
215
  * Every shape keeps `hasMore`, so a `select` that drops the body's own
136
216
  * completeness fields cannot hide that a list was cut short.
@@ -138,6 +218,8 @@ function notionFailure(status, headers, payload, withheld = false) {
138
218
  function page(data, call) {
139
219
  if (!isRecord(data) || typeof data["has_more"] !== "boolean" || !Array.isArray(data["results"]))
140
220
  return undefined;
221
+ if (!data["has_more"] && incomplete(data))
222
+ return { hasMore: true };
141
223
  const hasMore = data["has_more"];
142
224
  const next = typeof data["next_cursor"] === "string" && data["next_cursor"] !== "" ? data["next_cursor"] : undefined;
143
225
  if (!hasMore || !next)
@@ -153,6 +235,25 @@ function page(data, call) {
153
235
  }
154
236
  return { hasMore, next, param: "start_cursor", ...(call.method === "GET" ? {} : { in: "body" }) };
155
237
  }
238
+ /** Whether a query stopped at Notion's per-query result cap rather than at its last row. */
239
+ function incomplete(data) {
240
+ const status = data["request_status"];
241
+ return isRecord(status) && status["type"] === "incomplete";
242
+ }
243
+ /**
244
+ * A read copied into a write: a file link whose signature this connector
245
+ * withheld would be stored as the placeholder, or fail an exact `old_str`
246
+ * match. Refused before dispatch, named tools and generic writes alike.
247
+ */
248
+ function refuseWithheldLinks(call) {
249
+ if (call.method === "GET" || call.method === "HEAD")
250
+ return;
251
+ if (READ_POSTS.some(([method, path]) => method === call.method && path === call.op.path))
252
+ return;
253
+ if (!carriesWithheldLink(call.body, NOTION_FILE_HOSTS))
254
+ return;
255
+ invalid("This write carries a Notion file link whose signature Connecta withheld ([redacted]), so Notion would store the placeholder. A redacted file URL cannot be copied into a write: re-upload the file (POST /v1/file_uploads), reference the existing block instead, and match Markdown edits on the text around the media rather than its URL.");
256
+ }
156
257
  /** The pinned index, shared by every Notion REST connector in the deployment. */
157
258
  let index;
158
259
  let transport;
@@ -190,9 +291,14 @@ const VENDOR = {
190
291
  ? notionTransport()
191
292
  : `This operation is served from ${server}, which this connector does not reach.`;
192
293
  },
193
- failure: (status, headers, payload, call) => notionFailure(status, headers, payload, call ? SAFETY.withholdsErrors(call.op) : false),
294
+ failure: (status, headers, payload, call) => notionFailure(status, headers, sanitizeLinks(payload), call ? SAFETY.withholdsErrors(call.op) : false),
194
295
  readPosts: READ_POSTS,
195
296
  valueSafety: SAFETY,
297
+ // Not an envelope to unwrap: page Markdown carries pre-signed file links
298
+ // inside its text, where redaction's URL rule cannot reach. Every success
299
+ // response passes here, named tools and paging included.
300
+ result: sanitizeLinks,
301
+ admit: refuseWithheldLinks,
196
302
  page,
197
303
  upload: 'Create the upload with POST /v1/file_uploads and mode "external_url" instead; this connector sends no multipart bodies.',
198
304
  bodyHint: "Notion's JSON request body as notion_api_details lists it",
@@ -226,9 +332,26 @@ function plainText(value) {
226
332
  return "";
227
333
  return value.map((run) => (typeof run?.plain_text === "string" ? run.plain_text : "")).join("");
228
334
  }
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 } }];
335
+ /**
336
+ * Wrap a plain string as the rich-text array Notion expects. Notion rejects a
337
+ * run over 2,000 characters, so a longer string becomes consecutive runs, cut
338
+ * between UTF-16 surrogate pairs, never inside one. Past 100 runs Notion
339
+ * rejects the array itself, so that refuses here with the way forward.
340
+ */
341
+ function richText(value, field) {
342
+ const runs = [];
343
+ let rest = value;
344
+ while (rest.length > MAX_TEXT_CONTENT) {
345
+ const high = rest.charCodeAt(MAX_TEXT_CONTENT - 1);
346
+ const cut = high >= 0xd800 && high <= 0xdbff ? MAX_TEXT_CONTENT - 1 : MAX_TEXT_CONTENT;
347
+ runs.push(rest.slice(0, cut));
348
+ rest = rest.slice(cut);
349
+ }
350
+ runs.push(rest);
351
+ if (runs.length > MAX_RICH_TEXT_RUNS) {
352
+ invalid(`${field} is ${value.length} characters; Notion accepts at most ${MAX_RICH_TEXT_RUNS} runs of ${MAX_TEXT_CONTENT} characters in one rich-text value. Split it across several blocks.`);
353
+ }
354
+ return runs.map((content) => ({ type: "text", text: { content } }));
232
355
  }
233
356
  function userRef(value) {
234
357
  if (!value || typeof value !== "object")
@@ -290,10 +413,11 @@ function projectPropertyValue(value) {
290
413
  .map((related) => related?.id ?? null)
291
414
  .filter((related) => typeof related === "string");
292
415
  case "formula":
293
- return value.formula?.[value.formula?.type] ?? null;
416
+ // Notion answers `unsupported` for a value it cannot compute through the API.
417
+ return value.formula?.type === "unsupported" ? null : (value.formula?.[value.formula?.type] ?? null);
294
418
  case "rollup": {
295
419
  const rollup = value.rollup;
296
- if (!rollup)
420
+ if (!rollup || rollup.type === "unsupported")
297
421
  return null;
298
422
  if (rollup.type === "array") {
299
423
  return (rollup.array ?? []).map(projectPropertyValue);
@@ -343,11 +467,20 @@ function parentRef(parent) {
343
467
  return null;
344
468
  return { type, id: typeof parent[type] === "string" ? parent[type] : null };
345
469
  }
470
+ /**
471
+ * An icon as one string: the emoji itself, a native Notion icon's name, a
472
+ * workspace custom emoji as `:name:`, or an image URL.
473
+ */
346
474
  function iconRef(icon) {
347
475
  if (!icon || typeof icon !== "object")
348
476
  return null;
349
477
  if (typeof icon.emoji === "string")
350
478
  return icon.emoji;
479
+ if (icon.type === "icon" && typeof icon.icon?.name === "string")
480
+ return icon.icon.name;
481
+ if (icon.type === "custom_emoji" && typeof icon.custom_emoji?.name === "string") {
482
+ return `:${icon.custom_emoji.name}:`;
483
+ }
351
484
  return icon.external?.url ?? icon.file?.url ?? null;
352
485
  }
353
486
  function projectPage(page, select) {
@@ -537,7 +670,7 @@ const PAGE_OUTPUT_SCHEMA = {
537
670
  properties: { type: { type: "string" }, id: { type: ["string", "null"] } },
538
671
  required: ["type", "id"],
539
672
  },
540
- icon: { type: ["string", "null"], description: "Emoji or icon URL." },
673
+ icon: { type: ["string", "null"], description: "Emoji, native icon name, :custom_emoji:, or icon URL." },
541
674
  created_time: { type: ["string", "null"] },
542
675
  last_edited_time: { type: ["string", "null"] },
543
676
  created_by: { type: ["object", "null"] },
@@ -601,6 +734,17 @@ function pagination(args, defaultPageSize) {
601
734
  ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
602
735
  };
603
736
  }
737
+ /** Both page GETs and data-source POST queries filter properties on the URL. */
738
+ function propertyQuery(properties, maxFilters) {
739
+ if (properties === undefined)
740
+ return {};
741
+ // The title column's name varies, but its id is always `title`. Fetch it
742
+ // even when projection drops the column so the top-level title survives.
743
+ const filters = [...new Set([...properties, "title"])];
744
+ // Filtering is an optimization. An oversized selection still needs every
745
+ // requested column, so retrieve all properties and keep projecting locally.
746
+ return maxFilters !== undefined && filters.length > maxFilters ? {} : { filter_properties: filters };
747
+ }
604
748
  /** Exactly-one-of validation, phrased so the agent knows what to send next. */
605
749
  function requireExactlyOne(provided, hint) {
606
750
  const present = provided.filter(([, value]) => value !== undefined && value !== null && value !== "");
@@ -697,7 +841,9 @@ function namedTools(defaultPageSize) {
697
841
  },
698
842
  outputSchema: PAGE_OUTPUT_SCHEMA,
699
843
  handler: async (args, ctx) => {
700
- const payload = await notion(ctx, "GET", `/v1/pages/${id(args.page_id)}`);
844
+ const payload = await notion(ctx, "GET", `/v1/pages/${id(args.page_id)}`, {
845
+ query: propertyQuery(args.properties, MAX_PAGE_PROPERTY_FILTERS),
846
+ });
701
847
  if (args.raw)
702
848
  return payload;
703
849
  return projectPage(payload, args.properties);
@@ -875,17 +1021,30 @@ function namedTools(defaultPageSize) {
875
1021
  required: ["data_source_id"],
876
1022
  additionalProperties: false,
877
1023
  },
878
- outputSchema: listOutputSchema(PAGE_OUTPUT_SCHEMA),
1024
+ outputSchema: {
1025
+ ...listOutputSchema(PAGE_OUTPUT_SCHEMA),
1026
+ properties: {
1027
+ ...listOutputSchema(PAGE_OUTPUT_SCHEMA)["properties"],
1028
+ incomplete: {
1029
+ type: "boolean",
1030
+ description: "Present when Notion stopped this query at its 10,000-result cap: has_more is false but rows are missing. Narrow the filter.",
1031
+ },
1032
+ },
1033
+ },
879
1034
  handler: async (args, ctx) => {
880
1035
  const body = pagination(args, defaultPageSize);
881
1036
  if (args.filter)
882
1037
  body["filter"] = args.filter;
883
1038
  if (args.sorts)
884
1039
  body["sorts"] = args.sorts;
885
- const payload = await notion(ctx, "POST", `/v1/data_sources/${id(args.data_source_id)}/query`, { body });
1040
+ const payload = await notion(ctx, "POST", `/v1/data_sources/${id(args.data_source_id)}/query`, {
1041
+ query: propertyQuery(args.properties),
1042
+ body,
1043
+ });
886
1044
  if (args.raw)
887
1045
  return payload;
888
- return mappedListEnvelope(payload, (row) => projectPage(row, args.properties));
1046
+ const rows = mappedListEnvelope(payload, (row) => projectPage(row, args.properties));
1047
+ return incomplete(payload) ? { ...rows, incomplete: true } : rows;
889
1048
  },
890
1049
  },
891
1050
  {
@@ -944,7 +1103,7 @@ function namedTools(defaultPageSize) {
944
1103
  }
945
1104
  const properties = { ...args.properties };
946
1105
  if (args.title !== undefined)
947
- properties[args.title_property ?? "title"] = { title: richText(args.title) };
1106
+ properties[args.title_property ?? "title"] = { title: richText(args.title, "title") };
948
1107
  const body = {
949
1108
  parent: parentKey === "parent_page_id"
950
1109
  ? { type: "page_id", page_id: parentValue }
@@ -1014,16 +1173,16 @@ function namedTools(defaultPageSize) {
1014
1173
  ["children", args.children],
1015
1174
  ], "Use text for plain paragraphs, checklist for to-do items, or children for raw Notion blocks.");
1016
1175
  const children = kind === "text"
1017
- ? value.map((line) => ({
1176
+ ? value.map((line, at) => ({
1018
1177
  object: "block",
1019
1178
  type: "paragraph",
1020
- paragraph: { rich_text: richText(line) },
1179
+ paragraph: { rich_text: richText(line, `text[${at}]`) },
1021
1180
  }))
1022
1181
  : kind === "checklist"
1023
- ? value.map((line) => ({
1182
+ ? value.map((line, at) => ({
1024
1183
  object: "block",
1025
1184
  type: "to_do",
1026
- to_do: { rich_text: richText(line), checked: false },
1185
+ to_do: { rich_text: richText(line, `checklist[${at}]`), checked: false },
1027
1186
  }))
1028
1187
  : value;
1029
1188
  if (children.length === 0)
@@ -1,11 +1,11 @@
1
1
  // Generated by scripts/generate-providers.mjs; do not edit.
2
2
  export const skill = {
3
- "content": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- `select` and `status` writes must use an existing option name; inventing\n one fails. The schema lists the valid options.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n",
3
+ "content": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- A `status` write must use an existing option name; inventing one fails.\n A new `select` or `multi_select` name does not fail: when the connection\n can write to the data source, Notion adds it to the schema as a new\n option. Use the options the schema lists unless a schema change is meant.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n",
4
4
  "instructionsHeading": "Workspace instructions",
5
5
  "fragments": {
6
6
  "oauth_head": "# Notion MCP usage\n\nOfficial MCP interface: tool names, descriptions, argument schemas, and result\nschemas come from Notion's live server. Connecta preserves that catalog and\napplies release-reviewed classification. Reviewed writes stay writes even when Notion claims they only read.\n\nWorkspace purpose: ",
7
- "oauth": "\n\n- This connection acts as its OAuth-authorized user and reaches what that user\n can. It holds no integration token. An internal integration (bot) is a\n separate connector with `auth: { type: \"token\" }` under another id; never\n switch to it to recover a failed call here.\n- Discover the live catalog before assuming a tool exists. Notion can gate\n tools by workspace, account, client, and rollout independently of Connecta.\n- Start broad discovery with `notion-search`, then use `notion-fetch` on\n the exact page, database, data source, or object before changing it.\n- Use the live input schema as the contract. Notion owns these MCP schemas;\n the REST schemas of Connecta's token connector do not apply to MCP tools with\n similar names.\n- Session and agent tools can launch asynchronous work. Read session state and\n events before sending another message, waiting, or stopping a session.\n- File uploads and attachment tools create durable workspace state. Keep\n downloads inside the requested task and do not expose signed attachment URLs.\n- Connecta's generic REST tools (`notion_api_*`) and the `integration_*`\n helpers exist only on a token connector.\n- An `auth_required` failure means this connector's OAuth grant is missing or\n expired. Run `authorize_connector` for this connector id, then retry.\n",
8
- "key": "\n## Identity and tools\n\nThis is Connecta's REST connector to `api.notion.com`, acting as one internal\nintegration (a bot) with an operator-managed integration token. It reaches\nexactly the pages and data sources shared with that integration, and the\nintegration's capabilities decide which calls succeed: comment and\nuser-information capabilities are off by default. It never acts as a person.\nHosted Notion MCP is a separate OAuth connector.\n\n- Named tools cover the common path and flatten Notion's payloads:\n `integration_search` (titles), `integration_get_page` (properties),\n `integration_get_page_content` (blocks as text),\n `integration_get_data_source_schema`, and `integration_query_data_source`\n (rows). `integration_create_page` and `integration_append_blocks` author\n Notion's rich-text and block shapes from plain text, Markdown, or checklists.\n- Four generic tools reach every other operation in the pinned API index.\n `notion_api_search` finds operations, not content. `notion_api_details`\n returns one operation's parameters. `notion_api_read` calls GETs plus the\n read-only POST queries: search, data source and meeting note queries, and\n agent, session, and session event queries. `notion_api_write` calls every\n other POST, PATCH, and DELETE with `method` and `path` stated.\n- Every generic call is checked against the index before it is sent. An\n unknown path returns the nearest operations, and an unknown or missing\n parameter returns `validation.issues` with the accepted names. Nothing\n reached Notion, so fix the call from that answer.\n- The generic set replaces the old one-operation tools. Read a database\n container with `GET /v1/databases/{database_id}`, users with `GET /v1/users`\n or `GET /v1/users/me`, comments with `GET /v1/comments`, and a page as\n Markdown with `GET /v1/pages/{page_id}/markdown`. Update properties, trash or\n restore (`in_trash`) with `PATCH /v1/pages/{page_id}`, and comment with\n `POST /v1/comments`.\n- Refused by design: Notion's OAuth token endpoints (`/v1/oauth/*`) and\n multipart file sends. Import a file with `POST /v1/file_uploads` and\n `mode: \"external_url\"` instead.\n- Notion-hosted file URLs in pages and blocks are pre-signed: Connecta\n withholds their signature, credential, and security-token parameters, so a\n returned URL names the file but does not download it. Credential-named\n fields read as `\"[redacted]\"`, a database property named like a credential\n (`API token`) included. Failures of the OAuth, file upload, bot user, and\n agent operations carry Notion's code, not its message.\n- Not here: the hosted MCP's user identity, its search across connected\n apps, page duplication, team and skill search, attachment downloads, and\n page-to-skill conversion. Those need an OAuth connector.\n\n## Databases contain data sources\n\n- The id in a Notion database URL is a **database id**.\n `GET /v1/databases/{database_id}` through `notion_api_read` lists its\n `data_sources`, usually one. `integration_search` returns data sources\n directly and skips that step.\n- `integration_get_data_source_schema` and `integration_query_data_source`\n take the **data_source_id**, and so does `integration_create_page` when\n adding a row.\n\nSo the sequence for \"find rows in this database\" is the database read, then\n`integration_get_data_source_schema`, then `integration_query_data_source`.\n\n## Reading\n\n- `integration_get_page` returns properties. `integration_get_page_content`\n returns the body as flat blocks. Nested blocks (toggles, list children,\n table rows) need `depth`, and each level multiplies requests. A deep read\n stops at an internal ceiling and reports `truncated: true` rather than\n spending the whole rate-limit budget.\n- Notion truncates `title`, `rich_text`, `relation`, and `people` at 25\n entries. `integration_get_page` reports each in `truncated_properties` as\n `{ name, id }`; read the complete value with `notion_api_read`\n `GET /v1/pages/{page_id}/properties/{property_id}` and follow its cursor.\n- Every named read projects Notion's payload to ids, plain text, and\n flattened property values. Pass `raw: true` for Notion's untouched response\n when a specific field is missing. It is much larger. Narrow\n `integration_query_data_source` and `integration_get_page` with `properties`\n when the goal is fewer fields. `integration_get_page_content` with\n `raw: true` returns one level and ignores `depth`.\n\n## Appending is append-only\n\n`integration_append_blocks` adds children and nothing else. It cannot move,\nreorder, or replace an existing block, and a block appended through the API can\nnever be relocated by it afterwards. Get the position right the first time with\n`position` and `after_block_id`. Notion caps one call at 100 blocks.\n\n## Pagination\n\n- Named list tools take `page_size` (max 100) and return `has_more` with\n `next_cursor`; pass it back as `start_cursor`.\n- Generic results arrive as `{ status, data, page? }`. On a list,\n `page.next` is the cursor and `page.param` is `start_cursor`: send it in\n `query` for a GET, and in `body` when `page.in` is `body`, as on the POST\n queries. Pass `select` dot paths relative to `data`, such as `results.id`;\n `page` survives any `select`.\n- A view query is created with `notion_api_write`\n `POST /v1/views/{view_id}/queries`, a write because Notion stores it until\n `expires_at`. Its first page names the continuation in `page.path`: read\n `GET /v1/views/{view_id}/queries/{query_id}` with `start_cursor` in `query`.\n- Meeting-note queries have no cursor. `limit` caps at 50, and\n `page.hasMore` without `page.next` means more notes exist: narrow the\n filter (for example by `created_time`) rather than paging.\n- Cursors are opaque: never parse or construct one. Follow pages inside\n `execute_code` and reduce there.\n\n## Failures worth reading carefully\n\n- **404** means \"no such object\" *or* \"not shared with this integration\", and\n Notion will not say which. Never conclude a page was deleted from it; check\n that the page is shared with the integration in Notion.\n- **403** is not an expired token. The integration is missing a capability\n (comment capabilities are off by default) or the object was never shared.\n Re-authorizing cannot fix it; an operator must change it in Notion.\n- **429** carries a retry window. Notion allows an average of three requests\n per second per integration; Connecta admits 180 calls a minute, three at a\n time, per runtime. Wait out the window rather than retrying immediately.\n",
9
- "shared": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- `select` and `status` writes must use an existing option name; inventing\n one fails. The schema lists the valid options.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n"
7
+ "oauth": "\n\n- This connection acts as its OAuth-authorized user and reaches what that user\n can. It holds no integration token. An internal integration (bot) is a\n separate connector with `auth: { type: \"token\" }` under another id; never\n switch to it to recover a failed call here.\n- Discover the live catalog before assuming a tool exists. Notion can gate\n tools by workspace, account, client, and rollout independently of Connecta.\n `notion-get-tool-access` reports what the workspace's plan allows.\n- Start broad discovery with `notion-search`, then use `notion-fetch` on\n the exact page, database, data source, or object before changing it.\n- Use the live input schema as the contract. Notion owns these MCP schemas;\n the REST schemas of Connecta's token connector do not apply to MCP tools with\n similar names.\n- The next-step card tools return guidance without enabling access. Call\n `notion-check-mcp-next-steps` only when fetch instructs you to, at most\n once. Call `notion-show-advanced-analysis-next-steps` once at turn end\n only when a multi-source query requires the full version.\n- Session and agent tools can launch asynchronous work. Read session state and\n events before sending another message, waiting, or stopping a session.\n- File uploads and attachment tools create durable workspace state. Keep\n downloads inside the requested task and do not expose signed attachment URLs.\n `notion-get-file-download-urls` returns signed download URLs that expire\n after a few minutes. Use them only for the requested download and keep\n stable `notion-file-block://` references in edited or copied Markdown.\n `notion-download-skill` also returns a signed, expiring archive URL.\n The token connector's REST redaction does not apply to hosted tools;\n do not repeat signed download URLs in replies.\n- `notion-upload-skill` is a write for both actions. `prepare` returns a\n credential-bearing upload URL and headers with a ten-minute expiry;\n keep them inside the upload flow and out of replies. `complete` replaces\n the target page's title, Description, body, and Files.\n- Connecta's generic REST tools (`notion_api_*`) and the `integration_*`\n helpers exist only on a token connector.\n- An `auth_required` failure means this connector's OAuth grant is missing or\n expired. Run `authorize_connector` for this connector id, then retry.\n",
8
+ "key": "\n## Identity and tools\n\nThis is Connecta's REST connector to `api.notion.com`, acting as one internal\nintegration (a bot) with an operator-managed integration token. It reaches\nexactly the pages and data sources shared with that integration, and the\nintegration's capabilities decide which calls succeed: comment and\nuser-information capabilities are off by default. It never acts as a person.\nHosted Notion MCP is a separate OAuth connector.\n\n- Named tools cover the common path and flatten Notion's payloads:\n `integration_search` (titles), `integration_get_page` (properties),\n `integration_get_page_content` (blocks as text),\n `integration_get_data_source_schema`, and `integration_query_data_source`\n (rows). `integration_create_page` and `integration_append_blocks` author\n Notion's rich-text and block shapes from plain text, Markdown, or checklists.\n- Four generic tools reach every other operation in the pinned API index.\n `notion_api_search` finds operations, not content. `notion_api_details`\n returns one operation's parameters. `notion_api_read` calls GETs plus the\n read-only POST queries: search, data source and meeting note queries, and\n agent, session, and session event queries. `notion_api_write` calls every\n other POST, PATCH, and DELETE with `method` and `path` stated.\n- Every generic call is checked against the index before it is sent. An\n unknown path returns the nearest operations, and an unknown or missing\n parameter returns `validation.issues` with the accepted names. Nothing\n reached Notion, so fix the call from that answer.\n- The generic set replaces the old one-operation tools. Read a database\n container with `GET /v1/databases/{database_id}`, users with `GET /v1/users`\n or `GET /v1/users/me`, and comments with `GET /v1/comments`. Update\n properties, trash or restore (`in_trash`) with `PATCH /v1/pages/{page_id}`,\n and comment with `POST /v1/comments` (`rich_text` or `markdown`).\n- Refused by design: Notion's OAuth token endpoints (`/v1/oauth/*`) and\n multipart file sends. Import a file with `POST /v1/file_uploads` and\n `mode: \"external_url\"` instead.\n- Notion-minted file links lose their whole query and fragment wherever\n they appear, including page Markdown, async task results, and error text.\n Connecta matches the actual authority: `file.notion.so`,\n `secure.notion-static.com`, `notionusercontent.com` and their subdomains,\n or the `prod-files-secure` and `secure.notion-static.com` S3 buckets,\n virtual-hosted or exactly the first path segment on an S3 endpoint.\n Escaped schemes, slashes, and query/fragment openers are recognized. The\n withheld span runs through the next whitespace, quote, or angle bracket;\n trailing closing punctuation comes back. A returned file URL names the\n file but cannot download it. Links at other authorities, including proxy\n URLs with a Notion URL inside their path or query, come back as written.\n AWS `X-Amz-*` and Google `X-Goog-*` signing `Signature`, `Credential`, and\n `Security-Token` values are withheld at any host.\n- A write whose Notion file link carries the exact `[redacted]` placeholder\n in its own query or fragment, plain or up to four layers encoded, is\n refused before dispatch. Each inline Markdown destination is checked,\n including adjacent links and images, balanced or escaped parentheses, and\n leading spaces, tabs, or one line ending. A marker after the destination's\n close is ordinary text. To keep a file, leave its block in place or\n re-upload it with `POST /v1/file_uploads`; to edit Markdown around media,\n match `old_str` on nearby text rather than the withheld URL.\n- Each string is scanned separately. Links split across rich-text runs,\n encoded hostnames or signing-parameter names, bare hosts without `//`,\n and userinfo before a Notion host are outside this scan's threat model.\n The shared URL rule still removes userinfo from whole URL fields.\n Credential-named fields read as `\"[redacted]\"`, a database property named\n like a credential (`API token`) included. Failures of the OAuth, file upload, bot user, and\n agent operations carry Notion's code, not its message.\n- Not here: the hosted MCP's user identity, its search across connected\n apps, page duplication, team and skill search, attachment downloads, and\n page-to-skill conversion. Those need an OAuth connector.\n\n## Databases contain data sources\n\n- The id in a Notion database URL is a **database id**.\n `GET /v1/databases/{database_id}` through `notion_api_read` lists its\n `data_sources`, usually one. `integration_search` returns data sources\n directly and skips that step.\n- `integration_get_data_source_schema` and `integration_query_data_source`\n take the **data_source_id**, and so does `integration_create_page` when\n adding a row.\n\nSo the sequence for \"find rows in this database\" is the database read, then\n`integration_get_data_source_schema`, then `integration_query_data_source`.\n\n## Reading\n\n- `integration_get_page` returns properties. To read or summarize a whole\n body, prefer `notion_api_read` `GET /v1/pages/{page_id}/markdown`: one\n request returns every nested level as Markdown (headings, lists, to-dos,\n toggles, tables, code, links, mentions, equations). A huge page comes back\n with `truncated: true` and the unread blocks in `unknown_block_ids`; read\n each id as a `page_id` on the same path.\n- `integration_get_page_content` returns the body as flat blocks with ids,\n for quoting a block or appending after one. Nested blocks (toggles, list\n children, table rows) need `depth`, and each level multiplies requests. A\n deep read stops at an internal ceiling and reports `truncated: true` rather\n than spending the whole rate-limit budget.\n- Notion truncates `title`, `rich_text`, `relation`, and `people` at 25\n entries. `integration_get_page` reports each in `truncated_properties` as\n `{ name, id }`; read the complete value with `notion_api_read`\n `GET /v1/pages/{page_id}/properties/{property_id}` and follow its cursor.\n- Every named read projects Notion's payload to ids, plain text, and\n flattened property values. Pass `raw: true` for the full response, with\n the same value-safety redaction, when a specific field is missing. It is\n much larger. Narrow\n `integration_query_data_source` and `integration_get_page` with `properties`\n when the goal is fewer fields. This also narrows the request to Notion with\n repeated `filter_properties` URL parameters, shrinking the response before\n Connecta reads it. The title property is always fetched so the projected\n `title` stays populated. Page retrieval allows at most 100 distinct filters,\n including the title id. A larger selection fetches all properties and still\n projects only the selected names locally. Data source queries have no\n documented filter count limit. `raw: true` returns the upstream response,\n filtered when those filters fit and full when page retrieval falls back.\n Omit `properties` for the full response on either tool.\n `integration_get_page_content` with `raw: true` returns one level and ignores\n `depth`.\n\n## Writing content\n\n`integration_append_blocks` adds children and nothing else. It cannot move,\nreorder, or replace an existing block, and a block appended through the API can\nnever be relocated by it afterwards. Get the position right the first time with\n`position` and `after_block_id`. Notion caps one call at 100 blocks. Text\nlonger than Notion's 2,000-character run limit is split into runs for you.\n\nFor formatted content, write Markdown to a page with `notion_api_write`\n`PATCH /v1/pages/{page_id}/markdown`. Append with\n`{\"type\":\"insert_content\",\"insert_content\":{\"content\":\"## Notes\\n- one\",\"position\":{\"type\":\"end\"}}}`\n(`start` prepends). Edit text in place with `update_content`, whose\n`content_updates` are `{ old_str, new_str }` pairs copied exactly from the\npage's Markdown, and `replace_content` swaps the whole page for `new_str`.\nNotion refuses an edit that would delete a child page or database with a\n`validation_error` naming them. Removing ordinary text needs no flag; to delete\nthose children, set `allow_deleting_content: true` inside the command body\n(`{\"type\":\"update_content\",\"update_content\":{\"content_updates\":[…],\"allow_deleting_content\":true}}`),\nand leave it out unless the task is to delete them. `integration_create_page`\ntakes the same Markdown for a new page's body.\n\n## Pagination\n\n- Named list tools take `page_size` (max 100) and return `has_more` with\n `next_cursor`; pass it back as `start_cursor`.\n- Generic results arrive as `{ status, data, page? }`. On a list,\n `page.next` is the cursor and `page.param` is `start_cursor`: send it in\n `query` for a GET, and in `body` when `page.in` is `body`, as on the POST\n queries. Pass `select` dot paths relative to `data`, such as `results.id`;\n `page` survives any `select`.\n- A view query is created with `notion_api_write`\n `POST /v1/views/{view_id}/queries`, a write because Notion stores it until\n `expires_at`. Its first page names the continuation in `page.path`: read\n `GET /v1/views/{view_id}/queries/{query_id}` with `start_cursor` in `query`.\n- Meeting-note queries have no cursor. `limit` caps at 50, and\n `page.hasMore` without `page.next` means more notes exist: narrow the\n filter (for example by `created_time`) rather than paging.\n- A data source query stops at 10,000 rows. `integration_query_data_source`\n then returns `incomplete: true`, and a generic query `page.hasMore` without\n `page.next`: narrow the filter rather than trusting the count.\n- Cursors are opaque: never parse or construct one. Follow pages inside\n `execute_code` and reduce there.\n\n## Failures worth reading carefully\n\n- **404** means \"no such object\" *or* \"not shared with this integration\", and\n Notion will not say which. Never conclude a page was deleted from it; check\n that the page is shared with the integration in Notion.\n- **403** is not an expired token. The integration is missing a capability\n (comment capabilities are off by default), the object was never shared, or\n the workspace is past its plan's block limit; Notion's message says which.\n Re-authorizing cannot fix it; an operator must change it in Notion.\n- **429** carries a retry window. Notion's [request limits](https://developers.notion.com/reference/request-limits)\n allow 600 requests a minute per connection on Business and Enterprise and\n 180 on other plans, plus a workspace-wide limit shared by all connections.\n Connecta defaults to 180 calls a minute, three at a time, per runtime.\n A token connector's optional `callAdmission` replaces that whole policy;\n account for other runtime traffic and tools that make multiple HTTP requests.\n Wait out the window rather than retrying immediately. A 429 that says the\n integration's API access is blocked is not retryable.\n- **5xx on a write** may still have been saved. When Notion names the\n object it committed, the failure says so: read that object instead of\n repeating the write. Otherwise check the target before trying again.\n",
9
+ "shared": "\n## Notion's data model\n\n- A database is a container; its rows and schema live in a *data source*\n inside it. Queries, schema reads, and new rows take the data source, and\n Notion rejects a database id in its place.\n- Property names in filters, sorts, and writes must match the schema exactly,\n including case. Read the schema before composing one.\n- A `status` write must use an existing option name; inventing one fails.\n A new `select` or `multi_select` name does not fail: when the connection\n can write to the data source, Notion adds it to the schema as a new\n option. Use the options the schema lists unless a schema change is meant.\n- Writes **replace** a property. Sending one item to a `multi_select` or\n `relation` drops the others, so send the complete intended value.\n- `rollup`, `formula`, `unique_id`, and the created and edited fields are\n computed. They cannot be written.\n- A data source's title column is rarely called \"title\". Read its name from\n the schema before creating or renaming a row.\n- Notion has **no idempotency key**. A retried page create or comment makes a\n duplicate. Confirm with a search before repeating a write that may have\n partially succeeded.\n"
10
10
  }
11
11
  };
@@ -7,11 +7,11 @@
7
7
  // never appears in a response. The OAuth endpoints mint, introspect, and
8
8
  // revoke tokens with a public integration's client secret and are refused.
9
9
  // A file upload's `upload_url` is Notion's own send endpoint and needs the
10
- // integration token, so it is kept. Notion-hosted file URLs in pages and
11
- // blocks are pre-signed: the shared URL rule withholds their signature,
12
- // credential, and security-token query parameters on every response, so a
13
- // returned URL names the file without granting access to it.
14
- import { refuse, safe } from "../_shared/rest/value-safety.js";
10
+ // integration token, so it is kept. Notion-minted file URLs are pre-signed:
11
+ // sanitizeLinks removes their whole query/fragment wherever they appear,
12
+ // including page Markdown and free-form async results, before projections
13
+ // or the shared whole-URL heuristic. The same scan precedes error mapping.
14
+ import { refuse, safe, withholdSignedLinksIn, } from "../_shared/rest/value-safety.js";
15
15
  const OAUTH = "Notion's OAuth token endpoints are not reachable through Connecta: they mint and revoke credentials with a public integration's client secret, which this connector never holds.";
16
16
  const UPLOAD = "A file upload's upload_url is Notion's own send endpoint, which needs the integration token; it grants nothing alone.";
17
17
  const AGENT = "Custom agents name their connected MCP server's host, never its URL or credentials.";
@@ -29,3 +29,28 @@ const OPERATIONS = {
29
29
  "GET /v1/agents/{agent_id}": safe(AGENT, ["connections.account.server_host"]),
30
30
  };
31
31
  export const NOTION_VALUE_SAFETY = { title: "Notion", operations: OPERATIONS };
32
+ /**
33
+ * Where Notion stores the files it signs. Its S3 buckets, virtual-hosted
34
+ * (`prod-files-secure.s3.us-west-2.amazonaws.com`) or as the first path
35
+ * segment on an S3 endpoint (`s3.us-west-2.amazonaws.com/secure.notion-static.com/…`,
36
+ * Notion's file object reference), and its own file, static, and image-proxy
37
+ * hosts and their subdomains (`file.notion.so`, `img.notionusercontent.com`).
38
+ * Never all of `amazonaws.com`, and never a host or path that merely
39
+ * contains one of these names: a person's link is their content.
40
+ */
41
+ export const NOTION_FILE_HOSTS = {
42
+ domains: ["file.notion.so", "secure.notion-static.com", "notionusercontent.com"],
43
+ buckets: ["prod-files-secure", "secure.notion-static.com"],
44
+ };
45
+ /**
46
+ * Every response with each pre-signed file link embedded in its strings
47
+ * withheld. Images in page Markdown render as `![caption](url)` and files,
48
+ * PDFs, audio, and video as `<file src="url">`, so a Notion-hosted file's
49
+ * pre-signed URL arrives inside text rather than as a field: in `GET` and
50
+ * `PATCH /v1/pages/{page_id}/markdown`, and in a completed async task's
51
+ * `result`, which is free-form. Every response and error body is scanned
52
+ * rather than those paths, so no route to page Markdown can skip it.
53
+ */
54
+ export function sanitizeLinks(body) {
55
+ return withholdSignedLinksIn(body, NOTION_FILE_HOSTS);
56
+ }
@@ -1519,7 +1519,7 @@ function overflowConnector(id, options) {
1519
1519
  };
1520
1520
  }
1521
1521
  },
1522
- callAdmission: options.callAdmission ?? OVERFLOW_ADMISSION,
1522
+ callAdmission: options.callAdmission === undefined ? OVERFLOW_ADMISSION : options.callAdmission,
1523
1523
  usageGuide: {
1524
1524
  content: usageGuide(purpose, environment, options.instructions),
1525
1525
  summary: `Overflow ${label}: money units per field, final statuses, deposits, donor-PII care, and writes via mutate.`,
@@ -4,6 +4,12 @@ export declare const PLANNING_CENTER_API_BASE_URL = "https://api.planningcentero
4
4
  /** The products behind `/<app>/v2`, in the order their guides list them. */
5
5
  export declare const PLANNING_CENTER_APPS: readonly ["people", "services", "groups", "check-ins", "calendar", "registrations", "giving", "publishing", "webhooks", "api"];
6
6
  export type PlanningCenterApp = (typeof PLANNING_CENTER_APPS)[number];
7
+ /**
8
+ * A product a deployment may remove from this connection. People is the one
9
+ * it may not: every product joins through its person ids, and the credential
10
+ * test reads `/people/v2/me`.
11
+ */
12
+ export type PlanningCenterExcludableApp = Exclude<PlanningCenterApp, "people">;
7
13
  /**
8
14
  * One reviewed `X-PCO-API-Version` per product, sent on every request.
9
15
  *
@@ -46,6 +52,27 @@ export interface PlanningCenterOptions {
46
52
  userAgent?: string;
47
53
  /** Default `per_page` for list tools. Defaults to 25; Planning Center's maximum is 100. */
48
54
  defaultPageSize?: number;
55
+ /**
56
+ * Products this connection never reaches, e.g. `["giving"]` for a
57
+ * deployment that keeps agents away from donor and financial data. Their
58
+ * named tools leave the catalog, the guide and description stop naming
59
+ * them, and the hatches refuse their paths before any request is sent.
60
+ * While any product is excluded, hatch paths must be literal, People lists
61
+ * are unreachable, and existing webhook subscriptions and their payloads
62
+ * are refused, because each could carry an excluded product's data.
63
+ * Defaults to none.
64
+ *
65
+ * Exclusion guarantees agents reach no excluded-product endpoint, no live
66
+ * cross-product query (People lists), and no webhook payload or
67
+ * subscription toggle. It does not hide records the church's automations
68
+ * or staff have copied into a reachable product: a Giving automation that
69
+ * adds first-time donors to a People workflow leaves readable workflow
70
+ * cards, as do notes, custom fields, and form answers. For a hard
71
+ * boundary, use a personal access token from a user with No Access in
72
+ * the excluded product; Planning Center sets permissions per product, per
73
+ * user, and a token carries its user's. Exclusion is defense in depth.
74
+ */
75
+ excludeApps?: readonly PlanningCenterExcludableApp[];
49
76
  /**
50
77
  * Replaces the default per-runtime budget (100 calls per 20 seconds, five at
51
78
  * a time). Split it when two connections share one token's user, because