@zackbart/connecta 0.30.0 → 0.32.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 (133) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +21 -16
  3. package/bin/connecta.mjs +1 -0
  4. package/bin/version.mjs +1 -1
  5. package/dist/auth-elicitation.js +1 -1
  6. package/dist/call-recovery.js +25 -0
  7. package/dist/catalog-service.js +7 -2
  8. package/dist/config-schema.d.ts +14 -7
  9. package/dist/config-schema.js +29 -10
  10. package/dist/config-value-sources.js +6 -3
  11. package/dist/config.d.ts +26 -8
  12. package/dist/config.js +38 -16
  13. package/dist/connector-access.d.ts +2 -1
  14. package/dist/connectors/guarded-fetch.d.ts +18 -1
  15. package/dist/connectors/guarded-fetch.js +21 -2
  16. package/dist/describe-config.d.ts +6 -3
  17. package/dist/describe-config.js +3 -2
  18. package/dist/errors.d.ts +3 -1
  19. package/dist/errors.js +1 -0
  20. package/dist/execute.js +16 -14
  21. package/dist/index.js +4 -3
  22. package/dist/invocation.js +39 -4
  23. package/dist/meta-tools.js +17 -19
  24. package/dist/operator-record.d.ts +2 -2
  25. package/dist/operator-record.js +2 -0
  26. package/dist/operator-ui/contract.d.ts +5 -4
  27. package/dist/operator-ui/generated.js +2 -2
  28. package/dist/operator-ui/model.d.ts +1 -1
  29. package/dist/operator-ui/view.d.ts +9 -0
  30. package/dist/operator-ui/view.js +12 -0
  31. package/dist/program-writes.js +4 -49
  32. package/dist/provider.d.ts +6 -5
  33. package/dist/provider.js +3 -4
  34. package/dist/providers/_shared/google/workspace.js +1 -1
  35. package/dist/providers/_shared/rest/dispatch.js +41 -0
  36. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  37. package/dist/providers/_shared/rest/operation-index.js +527 -0
  38. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  39. package/dist/providers/_shared/rest/tools.js +569 -0
  40. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  41. package/dist/providers/_shared/rest/value-safety.js +580 -0
  42. package/dist/providers/breeze/skill.generated.js +2 -2
  43. package/dist/providers/cloudflare/graphql.js +385 -0
  44. package/dist/providers/cloudflare/index.d.ts +47 -51
  45. package/dist/providers/cloudflare/index.js +188 -1997
  46. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  47. package/dist/providers/cloudflare/rest.d.ts +37 -0
  48. package/dist/providers/cloudflare/rest.js +1056 -0
  49. package/dist/providers/cloudflare/skill.generated.js +7 -4
  50. package/dist/providers/cloudflare/value-safety.js +1247 -0
  51. package/dist/providers/docs/index.d.ts +1 -1
  52. package/dist/providers/docs/index.js +2 -2
  53. package/dist/providers/drive/index.d.ts +1 -1
  54. package/dist/providers/drive/index.js +1 -1
  55. package/dist/providers/forms/index.d.ts +1 -1
  56. package/dist/providers/forms/index.js +2 -2
  57. package/dist/providers/github/skill.generated.js +2 -2
  58. package/dist/providers/gmail/index.d.ts +1 -1
  59. package/dist/providers/gmail/index.js +274 -23
  60. package/dist/providers/gmail/skill.generated.js +2 -2
  61. package/dist/providers/linear/index.js +1 -1
  62. package/dist/providers/mixpanel/index.d.ts +27 -12
  63. package/dist/providers/mixpanel/index.js +58 -6
  64. package/dist/providers/mixpanel/operations.js +20 -0
  65. package/dist/providers/mixpanel/rest.js +603 -0
  66. package/dist/providers/mixpanel/skill.generated.js +3 -2
  67. package/dist/providers/mixpanel/value-safety.js +16 -0
  68. package/dist/providers/notion/index.d.ts +35 -31
  69. package/dist/providers/notion/index.js +76 -1499
  70. package/dist/providers/notion/openapi.generated.js +75 -0
  71. package/dist/providers/notion/rest.d.ts +24 -0
  72. package/dist/providers/notion/rest.js +1186 -0
  73. package/dist/providers/notion/skill.generated.js +5 -4
  74. package/dist/providers/notion/value-safety.js +31 -0
  75. package/dist/providers/planning-center/index.d.ts +27 -0
  76. package/dist/providers/planning-center/index.js +286 -24
  77. package/dist/providers/planning-center/skill.generated.js +24 -4
  78. package/dist/providers/sheets/index.d.ts +1 -1
  79. package/dist/providers/sheets/index.js +3 -3
  80. package/dist/providers/slides/index.d.ts +1 -1
  81. package/dist/providers/slides/index.js +3 -3
  82. package/dist/providers/stripe/index.d.ts +26 -20
  83. package/dist/providers/stripe/index.js +103 -130
  84. package/dist/providers/stripe/openapi.generated.js +649 -0
  85. package/dist/providers/stripe/rest.d.ts +14 -0
  86. package/dist/providers/stripe/rest.js +395 -0
  87. package/dist/providers/stripe/skill.generated.js +7 -5
  88. package/dist/providers/stripe/value-safety.js +705 -0
  89. package/dist/providers/vercel/index.d.ts +31 -22
  90. package/dist/providers/vercel/index.js +96 -853
  91. package/dist/providers/vercel/openapi.generated.js +456 -0
  92. package/dist/providers/vercel/rest.d.ts +20 -0
  93. package/dist/providers/vercel/rest.js +1002 -0
  94. package/dist/providers/vercel/skill.generated.js +4 -5
  95. package/dist/providers/vercel/value-safety.js +328 -0
  96. package/dist/providers/wispr-flow/index.d.ts +14 -0
  97. package/dist/providers/wispr-flow/index.js +117 -0
  98. package/dist/providers/wispr-flow/skill.generated.js +8 -0
  99. package/dist/routes/mcp.js +30 -10
  100. package/dist/routes/operator.js +14 -7
  101. package/dist/routes/ui-config.js +2 -2
  102. package/dist/server.js +3 -0
  103. package/dist/skills.js +5 -4
  104. package/dist/tool-safety.js +13 -5
  105. package/dist/types.d.ts +1 -1
  106. package/dist/ui.d.ts +1 -1
  107. package/dist/ui.js +1 -1
  108. package/dist/usage-guide.js +3 -3
  109. package/dist/version.d.ts +1 -1
  110. package/dist/version.js +1 -1
  111. package/dist/write-outcome.js +47 -0
  112. package/documentation/README.md +1 -0
  113. package/documentation/architecture.md +155 -28
  114. package/documentation/auth.md +86 -27
  115. package/documentation/code-mode.md +81 -72
  116. package/documentation/deploying.md +10 -5
  117. package/documentation/integrating.md +72 -9
  118. package/documentation/meta-tools.md +52 -38
  119. package/documentation/operating.md +11 -8
  120. package/documentation/operator-tests.md +2 -3
  121. package/documentation/operator-ui.md +9 -3
  122. package/documentation/provider-auth-migration.md +308 -0
  123. package/documentation/provider-migration-0.29.md +18 -6
  124. package/examples/worker/AGENTS.md +5 -0
  125. package/examples/worker/README.md +2 -2
  126. package/examples/worker/src/connecta.config.ts +5 -0
  127. package/package.json +7 -2
  128. package/templates/node/AGENTS.md +6 -4
  129. package/templates/node/README.md +6 -4
  130. package/templates/node/package.json +1 -1
  131. package/templates/node/src/connecta.config.ts +5 -0
  132. package/dist/providers/vercel/mcp-ownership.js +0 -212
  133. package/dist/tool-safety.d.ts +0 -9
@@ -1,47 +1,15 @@
1
1
  import { skill } from "./skill.generated.js";
2
- import { apiConnector as api } from "../../connectors/api-connector.js";
3
- import { remoteMcp } from "../../connectors/remote-mcp.js";
2
+ import { apiConnector } from "../../connectors/api-connector.js";
4
3
  import { reviewedCatalog } from "../../catalog-drift.js";
5
- import { guardedFetch, retryAfterMs } from "../../connectors/guarded-fetch.js";
6
- import { ConnectorCallError } from "../../errors.js";
7
4
  import { keys, optionsOf, variants } from "../../config-schema.js";
8
- import { PROVIDER_COMMON } from "../../connectors/option-shapes.js";
9
- import { defineProvider } from "../../provider.js";
10
- /** Notion's REST origin. Every tool below speaks to exactly this host. */
11
- export const NOTION_API_BASE_URL = "https://api.notion.com";
5
+ import { CALL_ADMISSION, defineProvider } from "../../provider.js";
6
+ import { byAuth, hostedOAuth } from "../_shared/rest/dispatch.js";
7
+ import { MAX_PAGE_SIZE, notionRest } from "./rest.js";
8
+ export { NOTION_API_BASE_URL, NOTION_API_VERSION } from "./rest.js";
12
9
  /** Notion's official hosted MCP endpoint. */
13
10
  export const NOTION_MCP_ENDPOINT = "https://mcp.notion.com/mcp";
14
- /**
15
- * Pinned with no override. Notion's date-named versions keep working
16
- * indefinitely, which makes an override look harmless; it is not. `2026-03-11`
17
- * is where databases split into data sources, `archived` became `in_trash`, and
18
- * block append took a `position` object instead of an `after` string. Every
19
- * projection and write body here assumes those shapes, so an older version would
20
- * return quietly wrong results rather than fail loudly.
21
- */
22
- export const NOTION_API_VERSION = "2026-03-11";
23
- /** Notion's hard cap on `page_size` for every paginated endpoint. */
24
- const MAX_PAGE_SIZE = 100;
25
11
  /** Lean by default: smaller than Notion's 100 so a first read stays cheap. */
26
12
  const DEFAULT_PAGE_SIZE = 25;
27
- /** Notion's cap on `children` per append, and on blocks per page create. */
28
- const MAX_CHILDREN_PER_REQUEST = 100;
29
- /**
30
- * Ceiling on the fetches one `integration_get_page_content` walk may spend. Call admission
31
- * meters tool calls, not the requests inside them, so a deep `depth` would
32
- * otherwise drain the budget invisibly; the walk stops here and reports
33
- * `truncated: true` instead.
34
- */
35
- const MAX_CONTENT_REQUESTS = 20;
36
- /**
37
- * The largest response this connection will read.
38
- *
39
- * Notion's own limits put a legitimate answer nowhere near this: `page_size`
40
- * tops out at 100, and every payload here is JSON. Four mebibytes is a ceiling
41
- * on absurdity — a proxy that decided to answer with something enormous — not
42
- * a budget any real read has to think about.
43
- */
44
- const NOTION_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
45
13
  /**
46
14
  * Notion documents "an average of three requests per second, with some bursts
47
15
  * beyond the average allowed" per connection. 180 calls per minute is that
@@ -62,1420 +30,25 @@ const NOTION_ADMISSION = {
62
30
  },
63
31
  ],
64
32
  };
65
- /**
66
- * Map to what the caller should do next, not to what Notion's `code` says
67
- * happened. The two that are easy to mistranslate:
68
- *
69
- * - **403 is not an authentication failure.** The token is fine; the integration
70
- * lacks a capability or was never shared the object, and neither
71
- * `authorize_connector` nor a new token can fix it. Non-retryable call failure.
72
- * - **404 does not prove absence.** Notion returns `object_not_found` both for an
73
- * object that does not exist and for one never shared with the integration, and
74
- * will not say which — so not `not_found`, which exists to assert absence
75
- * (H11). The message names both possibilities.
76
- */
77
- function notionFailure(status, body, headers) {
78
- const code = typeof body?.["code"] === "string" ? body["code"] : undefined;
79
- const detail = typeof body?.["message"] === "string" && body["message"].trim()
80
- ? body["message"].trim()
81
- : `Notion returned HTTP ${status}.`;
82
- const retryAfter = retryAfterMs(headers);
83
- const labelled = code ? `Notion ${code}: ${detail}` : detail;
84
- if (status === 429) {
85
- const additional = body?.["additional_data"];
86
- const reason = additional && typeof additional === "object"
87
- ? additional["rate_limit_reason"]
88
- : undefined;
89
- return new ConnectorCallError("rate_limited", `${labelled}${typeof reason === "string" ? ` (limit: ${reason})` : ""} Notion allows roughly three requests per second per integration.`, { retryAfterMs: retryAfter ?? 1_000 });
90
- }
91
- if (status === 529) {
92
- // Notion documents 529 alongside 429: back off and respect Retry-After.
93
- return new ConnectorCallError("unavailable", `${labelled} Notion is overloaded; retry after the reported window.`, {
94
- retryAfterMs: retryAfter ?? 5_000,
95
- });
96
- }
97
- if (status === 401) {
98
- 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.`);
99
- }
100
- if (status === 403) {
101
- 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 });
102
- }
103
- if (status === 404) {
104
- 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 });
105
- }
106
- if (status === 400) {
107
- // Every documented 400 (validation_error, invalid_json, invalid_request,
108
- // invalid_request_url, missing_version, invalid_beta) is a malformed
109
- // request, which is exactly what invalid_args means to the caller.
110
- return new ConnectorCallError("invalid_args", labelled);
111
- }
112
- if (status === 409) {
113
- return new ConnectorCallError("unavailable", `${labelled} Notion reported a write conflict; this is safe to retry.`, { retryAfterMs: retryAfter ?? 1_000 });
114
- }
115
- if (status >= 500) {
116
- return new ConnectorCallError("unavailable", `${labelled} Notion is failing upstream.`, retryAfter !== undefined ? { retryAfterMs: retryAfter } : {});
117
- }
118
- return new ConnectorCallError("connector_call_failed", labelled, {
119
- retryable: false,
120
- });
33
+ function instructionsSection(instructions) {
34
+ const text = instructions?.trim();
35
+ return text ? `\n## ${skill.instructionsHeading}\n\n${text}\n` : "";
121
36
  }
122
37
  /**
123
- * The shared transport owns the mechanical, provider-independent guards: path
124
- * confinement re-checked after URL normalization, encoded query construction,
125
- * credential headers applied last and unshadowable, a refused 3xx (a redirect is
126
- * an instruction to re-send the credential to another origin), and a body
127
- * abandoned at `maxResponseBytes`. It deliberately owns no meaning — it never
128
- * reads a status code, because Notion's 403 is an ungranted capability while
129
- * another provider's is a token scope, and those want opposite next moves. The
130
- * mapper below supplies that. Note it must re-throw `ConnectorCallError`: a bare
131
- * catch around a body read swallows the transport's own refusal and turns a
132
- * response nobody was allowed to read into an empty success.
38
+ * Both guides are marked `required` for one reason: a Notion database is a
39
+ * container, and the rows and schema live in a data source inside it. The id
40
+ * in a database URL is a database id, and passing it to a query or a row
41
+ * create fails — a trap no input schema can teach, so the guide has to.
133
42
  */
134
- const send = guardedFetch({
135
- provider: "Notion",
136
- baseUrl: NOTION_API_BASE_URL,
137
- headers: { "Notion-Version": NOTION_API_VERSION },
138
- maxResponseBytes: NOTION_MAX_RESPONSE_BYTES,
139
- authenticate: async (ctx) => {
140
- const token = (await ctx.credential?.get())?.trim();
141
- if (!token) {
142
- throw new ConnectorCallError("auth_required", "No Notion integration token is configured for this connector. Call authorize_connector for recovery options. When available, an operator can add the token in this connection in the operator UI.");
143
- }
144
- return { Authorization: `Bearer ${token}` };
145
- },
146
- });
147
- async function notionRequest(ctx, request) {
148
- return await send(request, ctx, async (response) => {
149
- const parsed = await response.jsonResult();
150
- const payload = "value" in parsed ? parsed.value : undefined;
151
- if (!response.ok) {
152
- throw notionFailure(response.status, payload, response.headers);
153
- }
154
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
155
- throw new ConnectorCallError("connector_call_failed", "Notion returned an unreadable or non-object JSON response for a successful status. The request was sent, but its result could not be read; do not repeat a write to recover its result.", { retryable: false });
156
- }
157
- return payload;
158
- });
159
- }
160
- // Projections. Notion wraps every property in a discriminated object, every
161
- // string in an array of annotated rich-text runs, and every user in a nested
162
- // object, so a small query is tens of kilobytes of structure around a few
163
- // hundred bytes of meaning. Every read collapses that to ids, plain text, and
164
- // flattened values. Nothing is lost for good: the reads that can drop something
165
- // take `raw: true` and return Notion's untouched response instead.
166
- //
167
- // Unknown types unwrap rather than switch exhaustively, because Notion ships
168
- // additive changes to every pinned version at once — a property type newer than
169
- // this release degrades to its raw value instead of vanishing.
170
- /** Concatenate a rich-text array to its plain text. Safe for every variant. */
171
- function plainText(value) {
172
- if (!Array.isArray(value))
173
- return "";
174
- return value.map((run) => (typeof run?.plain_text === "string" ? run.plain_text : "")).join("");
175
- }
176
- /** Wrap a plain string as the single-run rich-text array Notion expects. */
177
- function richText(value) {
178
- return [{ type: "text", text: { content: value } }];
179
- }
180
- function userRef(value) {
181
- if (!value || typeof value !== "object")
182
- return null;
183
- return {
184
- id: typeof value.id === "string" ? value.id : null,
185
- ...(typeof value.name === "string" ? { name: value.name } : {}),
186
- };
187
- }
188
- /**
189
- * Flatten one Notion property value.
190
- *
191
- * The `default` branch is not laziness: Notion adds property types to every
192
- * API version simultaneously, so an exhaustive switch would start returning
193
- * `undefined` for a type that shipped after this release. Unwrapping
194
- * `value[value.type]` degrades an unknown type to its raw payload instead.
195
- */
196
- function projectPropertyValue(value) {
197
- const type = value?.type;
198
- switch (type) {
199
- case "title":
200
- case "rich_text":
201
- return plainText(value[type]);
202
- case "number":
203
- case "checkbox":
204
- case "url":
205
- case "email":
206
- case "phone_number":
207
- case "created_time":
208
- case "last_edited_time":
209
- return value[type] ?? null;
210
- case "select":
211
- case "status":
212
- return value[type]?.name ?? null;
213
- case "multi_select":
214
- return (value.multi_select ?? []).map((option) => option?.name ?? null);
215
- case "date":
216
- return value.date
217
- ? {
218
- start: value.date.start ?? null,
219
- end: value.date.end ?? null,
220
- ...(value.date.time_zone ? { time_zone: value.date.time_zone } : {}),
221
- }
222
- : null;
223
- case "people":
224
- return (value.people ?? []).map(userRef);
225
- case "created_by":
226
- case "last_edited_by":
227
- return userRef(value[type]);
228
- case "files":
229
- return (value.files ?? []).map((file) => ({
230
- name: file?.name ?? null,
231
- // A `file` upload carries a signed URL that expires; an `external` one
232
- // is a plain link. Agents want the link either way.
233
- url: file?.external?.url ?? file?.file?.url ?? null,
234
- }));
235
- case "relation":
236
- return (value.relation ?? [])
237
- .map((related) => related?.id ?? null)
238
- .filter((id) => typeof id === "string");
239
- case "formula":
240
- return value.formula?.[value.formula?.type] ?? null;
241
- case "rollup": {
242
- const rollup = value.rollup;
243
- if (!rollup)
244
- return null;
245
- if (rollup.type === "array") {
246
- return (rollup.array ?? []).map(projectPropertyValue);
247
- }
248
- return rollup[rollup.type] ?? null;
249
- }
250
- case "unique_id":
251
- return value.unique_id?.prefix
252
- ? `${value.unique_id.prefix}-${value.unique_id.number}`
253
- : (value.unique_id?.number ?? null);
254
- case "verification":
255
- return value.verification?.state ?? null;
256
- default:
257
- return type ? (value[type] ?? null) : null;
258
- }
259
- }
260
- /**
261
- * Unwrap one item from a paginated property-item list.
262
- *
263
- * `GET /v1/pages/{id}/properties/{id}` does *not* return page-shaped values.
264
- * On a page object the type key holds an array — `relation: [{ id }, ...]`.
265
- * In a property-item list each result holds a single object under its type
266
- * key — `{ object: "property_item", type: "relation", relation: { id } }` —
267
- * so feeding these to `projectPropertyValue` would `.map` a non-array and
268
- * throw a raw `TypeError` straight through the typed-failure contract.
269
- *
270
- * Notion paginates exactly four types (`title`, `rich_text`, `relation`,
271
- * `people`); everything else arrives as a single item whose shape already
272
- * matches a page property, so the default branch defers to the shared
273
- * projection and keeps unknown types degrading rather than vanishing.
274
- */
275
- function projectPropertyItem(item) {
276
- const type = item?.type;
277
- switch (type) {
278
- case "title":
279
- case "rich_text":
280
- return item[type]?.plain_text ?? "";
281
- case "relation":
282
- return item.relation?.id ?? null;
283
- case "people":
284
- return userRef(item.people);
285
- default:
286
- return projectPropertyValue(item);
287
- }
288
- }
289
- function projectProperties(source, select) {
290
- const properties = {};
291
- const truncated = [];
292
- if (!source || typeof source !== "object")
293
- return { properties, truncated };
294
- for (const [name, value] of Object.entries(source)) {
295
- if (select && !select.includes(name))
296
- continue;
297
- properties[name] = projectPropertyValue(value);
298
- if (value?.has_more === true) {
299
- truncated.push({
300
- name,
301
- id: typeof value?.id === "string" ? value.id : null,
302
- });
303
- }
304
- }
305
- return { properties, truncated };
306
- }
307
- /** The title property's name is arbitrary; its `type` is not. */
308
- function pageTitle(source) {
309
- if (!source || typeof source !== "object")
310
- return "";
311
- for (const value of Object.values(source)) {
312
- if (value?.type === "title")
313
- return plainText(value.title);
314
- }
315
- return "";
316
- }
317
- function parentRef(parent) {
318
- if (!parent || typeof parent !== "object")
319
- return null;
320
- const type = parent.type;
321
- if (typeof type !== "string")
322
- return null;
323
- return { type, id: typeof parent[type] === "string" ? parent[type] : null };
324
- }
325
- function iconRef(icon) {
326
- if (!icon || typeof icon !== "object")
327
- return null;
328
- if (typeof icon.emoji === "string")
329
- return icon.emoji;
330
- return icon.external?.url ?? icon.file?.url ?? null;
331
- }
332
- function projectPage(page, select) {
333
- const { properties, truncated } = projectProperties(page?.properties, select);
334
- return {
335
- id: page?.id ?? null,
336
- object: "page",
337
- title: pageTitle(page?.properties),
338
- url: page?.url ?? null,
339
- parent: parentRef(page?.parent),
340
- icon: iconRef(page?.icon),
341
- created_time: page?.created_time ?? null,
342
- last_edited_time: page?.last_edited_time ?? null,
343
- created_by: userRef(page?.created_by),
344
- last_edited_by: userRef(page?.last_edited_by),
345
- in_trash: page?.in_trash === true,
346
- is_archived: page?.is_archived === true,
347
- properties,
348
- ...(truncated.length ? { truncated_properties: truncated } : {}),
349
- };
350
- }
351
- /**
352
- * Identity fields only, no properties. A 25-result search across a populated
353
- * database would otherwise drag back hundreds of flattened values for results
354
- * the agent is about to discard; `integration_get_page` fetches them for the one that hit.
355
- */
356
- function projectSearchHit(hit) {
357
- if (hit?.object === "data_source") {
358
- return {
359
- id: hit?.id ?? null,
360
- object: "data_source",
361
- title: plainText(hit?.title) || (hit?.name ?? ""),
362
- database_id: hit?.parent?.database_id ?? null,
363
- url: hit?.url ?? null,
364
- last_edited_time: hit?.last_edited_time ?? null,
365
- };
366
- }
367
- return {
368
- id: hit?.id ?? null,
369
- object: hit?.object ?? "page",
370
- title: hit?.object === "page" ? pageTitle(hit?.properties) : plainText(hit?.title),
371
- url: hit?.url ?? null,
372
- parent: parentRef(hit?.parent),
373
- last_edited_time: hit?.last_edited_time ?? null,
374
- };
375
- }
376
- /** Flatten one block to its text plus the few fields its type actually adds. */
377
- function projectBlock(block, depth) {
378
- const type = block?.type;
379
- const payload = type ? block?.[type] : undefined;
380
- const projected = {
381
- id: block?.id ?? null,
382
- type: type ?? "unsupported",
383
- depth,
384
- text: plainText(payload?.rich_text),
385
- has_children: block?.has_children === true,
386
- };
387
- switch (type) {
388
- case "to_do":
389
- projected["checked"] = payload?.checked === true;
390
- break;
391
- case "code":
392
- projected["language"] = payload?.language ?? null;
393
- break;
394
- case "child_page":
395
- case "child_database":
396
- // Notion gives these a plain string title, not a rich-text array.
397
- projected["text"] = typeof payload?.title === "string" ? payload.title : "";
398
- break;
399
- case "image":
400
- case "video":
401
- case "file":
402
- case "pdf":
403
- projected["url"] = payload?.external?.url ?? payload?.file?.url ?? null;
404
- projected["text"] = plainText(payload?.caption);
405
- break;
406
- case "bookmark":
407
- case "embed":
408
- case "link_preview":
409
- projected["url"] = payload?.url ?? null;
410
- projected["text"] = plainText(payload?.caption);
411
- break;
412
- case "equation":
413
- projected["text"] = payload?.expression ?? "";
414
- break;
415
- case "table_row":
416
- projected["cells"] = (payload?.cells ?? []).map(plainText);
417
- break;
418
- case "callout":
419
- projected["icon"] = iconRef(payload?.icon);
420
- break;
421
- default:
422
- // A block type this projection does not model keeps its payload verbatim
423
- // rather than collapsing to an empty string, so a block type newer than
424
- // this release still carries its content.
425
- if (carriesUnprojectedContent(payload))
426
- projected["raw"] = payload;
427
- break;
428
- }
429
- return projected;
430
- }
431
- function carriesUnprojectedContent(payload) {
432
- if (!payload || typeof payload !== "object")
433
- return false;
434
- if (Array.isArray(payload.rich_text))
435
- return false;
436
- return Object.keys(payload).some((key) => key !== "color");
437
- }
438
- function projectUser(user) {
439
- return {
440
- id: user?.id ?? null,
441
- name: user?.name ?? null,
442
- type: user?.type ?? null,
443
- ...(user?.person?.email ? { email: user.person.email } : {}),
444
- ...(user?.bot ? { bot: true } : {}),
445
- };
446
- }
447
- function projectComment(comment) {
448
- return {
449
- id: comment?.id ?? null,
450
- discussion_id: comment?.discussion_id ?? null,
451
- created_time: comment?.created_time ?? null,
452
- created_by: userRef(comment?.created_by),
453
- text: plainText(comment?.rich_text),
454
- };
455
- }
456
- /**
457
- * A data source's schema, reduced to what a caller needs to filter and write.
458
- *
459
- * Select and status options are kept because a filter or a write that invents
460
- * an option name fails; everything else about a property collapses to its type.
461
- */
462
- function projectSchemaProperty(property) {
463
- const type = property?.type;
464
- const projected = {
465
- id: property?.id ?? null,
466
- type: type ?? null,
467
- };
468
- const payload = type ? property?.[type] : undefined;
469
- if (type === "select" || type === "multi_select") {
470
- projected["options"] = (payload?.options ?? []).map((option) => option?.name ?? null);
471
- }
472
- else if (type === "status") {
473
- projected["options"] = (payload?.options ?? []).map((option) => option?.name ?? null);
474
- projected["groups"] = (payload?.groups ?? []).map((group) => group?.name ?? null);
475
- }
476
- else if (type === "relation") {
477
- // Requests must send data_source_id; responses carry both. Give the caller
478
- // the one it is allowed to write with.
479
- projected["relation_data_source_id"] = payload?.data_source_id ?? null;
480
- }
481
- else if (type === "formula") {
482
- projected["expression"] = payload?.expression ?? null;
483
- }
484
- else if (type === "rollup") {
485
- projected["rollup"] = {
486
- relation_property_name: payload?.relation_property_name ?? null,
487
- rollup_property_name: payload?.rollup_property_name ?? null,
488
- function: payload?.function ?? null,
489
- };
490
- }
491
- return projected;
492
- }
493
- // ---------------------------------------------------------------------------
494
- // Shared schema fragments
495
- // ---------------------------------------------------------------------------
496
- // The compact renderer inlines every property description, so these three
497
- // shared strings are paid for once per tool that uses them. `integration_query_data_source`
498
- // carries all three plus a filter grammar and rendered past the 1,024-byte
499
- // compact budget until they were cut to the fact each one actually adds
500
- // ([#342](https://github.com/zackbart/connecta/issues/342)); the long versions
501
- // live in the usage guide, which is fetched once rather than per tool.
502
- const RAW_PROPERTY = {
503
- type: "boolean",
504
- description: "Return Notion's much larger unprojected response instead of the lean projection.",
505
- };
506
- const PAGE_SIZE_PROPERTY = {
507
- type: "integer",
508
- minimum: 1,
509
- maximum: MAX_PAGE_SIZE,
510
- description: `Results per page (1-${MAX_PAGE_SIZE}). Defaults to the connector's configured page size.`,
511
- };
512
- const START_CURSOR_PROPERTY = {
513
- type: "string",
514
- description: "Opaque next_cursor from the previous response. Pass it back verbatim.",
515
- };
516
- const PROPERTY_SELECT = {
517
- type: "array",
518
- items: { type: "string" },
519
- description: "Return only these property names. Omit for all. The cheapest way to shrink a result.",
520
- };
521
- function listOutputSchema(itemSchema) {
522
- return {
523
- type: "object",
524
- properties: {
525
- results: { type: "array", items: itemSchema },
526
- has_more: {
527
- type: "boolean",
528
- description: "True when another page exists.",
529
- },
530
- next_cursor: {
531
- type: ["string", "null"],
532
- description: "Pass as start_cursor to fetch the next page.",
533
- },
534
- },
535
- required: ["results", "has_more", "next_cursor"],
536
- };
537
- }
538
- const PAGE_OUTPUT_SCHEMA = {
539
- type: "object",
540
- description: "Projected page. With raw: true this is Notion's full page object instead.",
541
- properties: {
542
- id: { type: "string" },
543
- object: { type: "string" },
544
- title: { type: "string", description: "Plain text of the title property." },
545
- url: { type: ["string", "null"] },
546
- parent: {
547
- type: ["object", "null"],
548
- properties: {
549
- type: { type: "string" },
550
- id: { type: ["string", "null"] },
551
- },
552
- required: ["type", "id"],
553
- },
554
- icon: { type: ["string", "null"], description: "Emoji or icon URL." },
555
- created_time: { type: ["string", "null"] },
556
- last_edited_time: { type: ["string", "null"] },
557
- created_by: { type: ["object", "null"] },
558
- last_edited_by: { type: ["object", "null"] },
559
- in_trash: { type: "boolean" },
560
- is_archived: { type: "boolean" },
561
- properties: {
562
- type: "object",
563
- description: "Property name to flattened value: text for title/rich_text, name for select/status, array of names for multi_select, array of page ids for relation.",
564
- },
565
- truncated_properties: {
566
- type: "array",
567
- items: {
568
- type: "object",
569
- properties: {
570
- name: { type: "string" },
571
- id: {
572
- type: ["string", "null"],
573
- description: "Pass as property_id to integration_get_page_property.",
574
- },
575
- },
576
- required: ["name", "id"],
577
- },
578
- description: "Properties Notion truncated at 25 entries. Each carries the property_id integration_get_page_property needs to read the complete value.",
579
- },
580
- },
581
- required: ["id", "title", "properties"],
582
- };
583
- const BLOCK_OUTPUT_SCHEMA = {
584
- type: "object",
585
- properties: {
586
- id: { type: "string" },
587
- type: { type: "string" },
588
- depth: {
589
- type: "integer",
590
- description: "0 for direct children, 1 for their children, and so on.",
591
- },
592
- text: { type: "string", description: "Plain text of the block." },
593
- has_children: { type: "boolean" },
594
- checked: { type: "boolean", description: "to_do blocks only." },
595
- language: { type: ["string", "null"], description: "code blocks only." },
596
- url: { type: ["string", "null"], description: "Media and link blocks." },
597
- cells: {
598
- type: "array",
599
- items: { type: "string" },
600
- description: "table_row blocks only.",
601
- },
602
- icon: { type: ["string", "null"], description: "callout blocks only." },
603
- raw: {
604
- type: "object",
605
- description: "The block type's untouched payload, present only for types this projection does not model and whose content is not plain rich text.",
606
- },
607
- },
608
- required: ["id", "type", "depth", "text", "has_children"],
609
- };
610
- const USER_OUTPUT_SCHEMA = {
611
- type: "object",
612
- properties: {
613
- id: { type: "string" },
614
- name: { type: ["string", "null"] },
615
- type: { type: ["string", "null"], description: '"person" or "bot".' },
616
- email: { type: "string", description: "Person users only." },
617
- bot: { type: "boolean" },
618
- },
619
- required: ["id", "name", "type"],
620
- };
621
- const COMMENT_OUTPUT_SCHEMA = {
622
- type: "object",
623
- properties: {
624
- id: { type: "string" },
625
- discussion_id: { type: ["string", "null"] },
626
- created_time: { type: ["string", "null"] },
627
- created_by: { type: ["object", "null"] },
628
- text: { type: "string" },
629
- },
630
- required: ["id", "discussion_id", "text"],
631
- };
632
- // ---------------------------------------------------------------------------
633
- // Tools
634
- // ---------------------------------------------------------------------------
635
- function resolvePageSize(requested, fallback) {
636
- if (typeof requested === "number" && Number.isFinite(requested)) {
637
- return Math.min(Math.max(Math.trunc(requested), 1), MAX_PAGE_SIZE);
638
- }
639
- return fallback;
640
- }
641
- function listEnvelope(payload, results) {
642
- return {
643
- results,
644
- has_more: payload?.has_more === true,
645
- next_cursor: payload?.next_cursor ?? null,
646
- };
647
- }
648
- function mappedListEnvelope(payload, project) {
649
- return listEnvelope(payload, (payload?.results ?? []).map(project));
650
- }
651
- function pagination(args, defaultPageSize) {
652
- return {
653
- page_size: resolvePageSize(args.page_size, defaultPageSize),
654
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
655
- };
656
- }
657
- /** Exactly-one-of validation, phrased so the agent knows what to send next. */
658
- function requireExactlyOne(provided, hint) {
659
- const present = provided.filter(([, value]) => value !== undefined && value !== null && value !== "");
660
- if (present.length !== 1) {
661
- throw new ConnectorCallError("invalid_args", `Provide exactly one of ${provided.map(([name]) => name).join(", ")}. ${hint}`);
662
- }
663
- return present[0];
664
- }
665
- function buildTools(defaultPageSize) {
666
- return [
667
- // ---------------------------------------------------------------- reads
668
- {
669
- name: "integration_search",
670
- description: "Find pages and data sources by title across everything shared with this integration. Never searches page content — use integration_query_data_source for rows inside a database. Returns identity fields only.",
671
- annotations: { readOnlyHint: true },
672
- inputSchema: {
673
- type: "object",
674
- required: [],
675
- properties: {
676
- query: {
677
- type: "string",
678
- description: "Title substring to match. Omit to list everything shared with the integration.",
679
- },
680
- object_type: {
681
- type: "string",
682
- enum: ["page", "data_source"],
683
- description: "Restrict results to pages or to data sources. Omit for both.",
684
- },
685
- sort: {
686
- type: "string",
687
- enum: ["last_edited_desc", "last_edited_asc", "relevance"],
688
- description: "Result ordering. Defaults to Notion's relevance order.",
689
- },
690
- page_size: PAGE_SIZE_PROPERTY,
691
- start_cursor: START_CURSOR_PROPERTY,
692
- raw: RAW_PROPERTY,
693
- },
694
- additionalProperties: false,
695
- },
696
- outputSchema: listOutputSchema({
697
- type: "object",
698
- properties: {
699
- id: { type: "string" },
700
- object: { type: "string", description: '"page" or "data_source".' },
701
- title: { type: "string" },
702
- url: { type: ["string", "null"] },
703
- parent: { type: ["object", "null"] },
704
- database_id: {
705
- type: ["string", "null"],
706
- description: "Data source hits only: the containing database.",
707
- },
708
- last_edited_time: { type: ["string", "null"] },
709
- },
710
- required: ["id", "object", "title"],
711
- }),
712
- handler: async (args, ctx) => {
713
- const body = pagination(args, defaultPageSize);
714
- if (args.query)
715
- body["query"] = args.query;
716
- if (args.object_type) {
717
- body["filter"] = { property: "object", value: args.object_type };
718
- }
719
- if (args.sort === "relevance") {
720
- body["sort"] = { property: "relevance" };
721
- }
722
- else if (args.sort) {
723
- body["sort"] = {
724
- timestamp: "last_edited_time",
725
- direction: args.sort === "last_edited_asc" ? "ascending" : "descending",
726
- };
727
- }
728
- const payload = await notionRequest(ctx, {
729
- method: "POST",
730
- path: "/v1/search",
731
- body,
732
- });
733
- if (args.raw)
734
- return payload;
735
- return mappedListEnvelope(payload, projectSearchHit);
736
- },
737
- },
738
- {
739
- name: "integration_get_page",
740
- description: "Fetch one page's metadata and flattened property values by id. Returns the page's properties, not its body content — use integration_get_page_content for the blocks.",
741
- annotations: { readOnlyHint: true },
742
- inputSchema: {
743
- type: "object",
744
- properties: {
745
- page_id: {
746
- type: "string",
747
- description: "Notion page id, with or without dashes.",
748
- },
749
- properties: PROPERTY_SELECT,
750
- raw: RAW_PROPERTY,
751
- },
752
- required: ["page_id"],
753
- additionalProperties: false,
754
- },
755
- outputSchema: PAGE_OUTPUT_SCHEMA,
756
- handler: async (args, ctx) => {
757
- const payload = await notionRequest(ctx, {
758
- method: "GET",
759
- path: `/v1/pages/${encodeURIComponent(args.page_id)}`,
760
- });
761
- if (args.raw)
762
- return payload;
763
- return projectPage(payload, args.properties);
764
- },
765
- },
766
- {
767
- name: "integration_get_page_content",
768
- // `raw: true` returns the requested level exactly as Notion sent it and
769
- // does not walk nested children, so `depth` is ignored alongside it: a
770
- // raw read of a deep page yields one level.
771
- description: "Read a page's body as a flat list of blocks reduced to plain text. Each block keeps its id, type, and depth so it can be quoted, appended after, or drilled into. Nested content requires depth > 0.",
772
- annotations: { readOnlyHint: true },
773
- inputSchema: {
774
- type: "object",
775
- properties: {
776
- block_id: {
777
- type: "string",
778
- description: "Page id, or any block id to read that block's children. A page id is a valid block id.",
779
- },
780
- depth: {
781
- type: "integer",
782
- minimum: 0,
783
- maximum: 2,
784
- description: "How many levels of nested children to follow. 0 (default) returns direct children only. Each level multiplies downstream requests.",
785
- },
786
- page_size: PAGE_SIZE_PROPERTY,
787
- start_cursor: START_CURSOR_PROPERTY,
788
- raw: {
789
- ...RAW_PROPERTY,
790
- 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.`,
791
- },
792
- },
793
- required: ["block_id"],
794
- additionalProperties: false,
795
- },
796
- outputSchema: {
797
- ...listOutputSchema(BLOCK_OUTPUT_SCHEMA),
798
- properties: {
799
- ...listOutputSchema(BLOCK_OUTPUT_SCHEMA)["properties"],
800
- truncated: {
801
- type: "boolean",
802
- description: "True when the nested walk stopped at its request ceiling. Some descendants are missing; re-read a specific block_id to continue.",
803
- },
804
- },
805
- required: ["results", "has_more", "next_cursor", "truncated"],
806
- },
807
- handler: async (args, ctx) => {
808
- const pageSize = resolvePageSize(args.page_size, defaultPageSize);
809
- const top = await notionRequest(ctx, {
810
- method: "GET",
811
- path: `/v1/blocks/${encodeURIComponent(args.block_id)}/children`,
812
- query: pagination(args, pageSize),
813
- });
814
- if (args.raw)
815
- return top;
816
- const maxDepth = typeof args.depth === "number" ? args.depth : 0;
817
- let spent = 1;
818
- let truncated = false;
819
- const results = [];
820
- const walk = async (blocks, depth) => {
821
- for (const block of blocks) {
822
- results.push(projectBlock(block, depth));
823
- if (depth >= maxDepth || block?.has_children !== true)
824
- continue;
825
- if (spent >= MAX_CONTENT_REQUESTS) {
826
- truncated = true;
827
- continue;
828
- }
829
- spent += 1;
830
- const child = await notionRequest(ctx, {
831
- method: "GET",
832
- path: `/v1/blocks/${encodeURIComponent(block.id)}/children`,
833
- query: { page_size: MAX_PAGE_SIZE },
834
- });
835
- // Nested levels take their first page only; a block with more than
836
- // 100 children is re-read directly rather than paged here.
837
- if (child?.has_more === true)
838
- truncated = true;
839
- await walk(child?.results ?? [], depth + 1);
840
- }
841
- };
842
- await walk(top?.results ?? [], 0);
843
- return {
844
- ...listEnvelope(top, results),
845
- truncated,
846
- };
847
- },
848
- },
849
- {
850
- name: "integration_get_page_property",
851
- description: "Fetch one page property completely, paginating past the 25-entry limit that integration_get_page reports in truncated_properties. Use for title, rich_text, relation, and people properties — the four Notion paginates.",
852
- annotations: { readOnlyHint: true },
853
- inputSchema: {
854
- type: "object",
855
- properties: {
856
- page_id: { type: "string", description: "Notion page id." },
857
- property_id: {
858
- type: "string",
859
- description: "The property's id, from integration_get_data_source_schema or integration_get_page's truncated_properties — not its name.",
860
- },
861
- page_size: PAGE_SIZE_PROPERTY,
862
- start_cursor: START_CURSOR_PROPERTY,
863
- raw: RAW_PROPERTY,
864
- },
865
- required: ["page_id", "property_id"],
866
- additionalProperties: false,
867
- },
868
- outputSchema: {
869
- type: "object",
870
- description: "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.",
871
- properties: {
872
- type: {
873
- type: ["string", "null"],
874
- description: 'The property\'s own type, never the "property_item" envelope.',
875
- },
876
- value: { description: "Flattened value for a single-value property." },
877
- results: {
878
- type: "array",
879
- description: "Flattened values for a paginated property.",
880
- },
881
- has_more: { type: "boolean" },
882
- next_cursor: { type: ["string", "null"] },
883
- },
884
- required: ["type"],
885
- },
886
- handler: async (args, ctx) => {
887
- const payload = await notionRequest(ctx, {
888
- method: "GET",
889
- path: `/v1/pages/${encodeURIComponent(args.page_id)}/properties/${encodeURIComponent(args.property_id)}`,
890
- query: pagination(args, defaultPageSize),
891
- });
892
- if (args.raw)
893
- return payload;
894
- if (payload?.object === "list") {
895
- return {
896
- // A list envelope's own `type` is the literal "property_item";
897
- // the property's real type sits one level down.
898
- type: payload?.property_item?.type ?? payload?.type ?? null,
899
- ...mappedListEnvelope(payload, projectPropertyItem),
900
- };
901
- }
902
- return {
903
- type: payload?.type ?? null,
904
- value: projectPropertyValue(payload),
905
- };
906
- },
907
- },
908
- {
909
- name: "integration_get_database",
910
- description: "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 integration_query_data_source and integration_get_data_source_schema need.",
911
- annotations: { readOnlyHint: true },
912
- inputSchema: {
913
- type: "object",
914
- properties: {
915
- database_id: { type: "string", description: "Notion database id." },
916
- },
917
- required: ["database_id"],
918
- additionalProperties: false,
919
- },
920
- outputSchema: {
921
- type: "object",
922
- properties: {
923
- id: { type: "string" },
924
- title: { type: "string" },
925
- url: { type: ["string", "null"] },
926
- parent: { type: ["object", "null"] },
927
- in_trash: { type: "boolean" },
928
- is_inline: { type: "boolean" },
929
- data_sources: {
930
- type: "array",
931
- description: "The queryable data sources. Most databases have exactly one.",
932
- items: {
933
- type: "object",
934
- properties: { id: { type: "string" }, name: { type: "string" } },
935
- required: ["id", "name"],
936
- },
937
- },
938
- },
939
- required: ["id", "title", "data_sources"],
940
- },
941
- handler: async (args, ctx) => {
942
- const payload = await notionRequest(ctx, {
943
- method: "GET",
944
- path: `/v1/databases/${encodeURIComponent(args.database_id)}`,
945
- });
946
- return {
947
- id: payload?.id ?? null,
948
- title: plainText(payload?.title),
949
- url: payload?.url ?? null,
950
- parent: parentRef(payload?.parent),
951
- in_trash: payload?.in_trash === true,
952
- is_inline: payload?.is_inline === true,
953
- data_sources: (payload?.data_sources ?? []).map((source) => ({
954
- id: source?.id ?? null,
955
- name: source?.name ?? null,
956
- })),
957
- };
958
- },
959
- },
960
- {
961
- name: "integration_get_data_source_schema",
962
- description: "List a data source's properties with their ids, types, and select/status options. Read this before filtering, sorting, or writing — filters and property names that do not match the schema exactly are rejected.",
963
- annotations: { readOnlyHint: true },
964
- inputSchema: {
965
- type: "object",
966
- properties: {
967
- data_source_id: {
968
- type: "string",
969
- description: "Data source id from integration_get_database or integration_search, not a database id.",
970
- },
971
- raw: RAW_PROPERTY,
972
- },
973
- required: ["data_source_id"],
974
- additionalProperties: false,
975
- },
976
- outputSchema: {
977
- type: "object",
978
- properties: {
979
- id: { type: "string" },
980
- name: { type: "string" },
981
- database_id: { type: ["string", "null"] },
982
- title_property: {
983
- type: ["string", "null"],
984
- description: "Name of the title-typed property. integration_create_page needs this to title a row.",
985
- },
986
- properties: {
987
- type: "object",
988
- description: "Property name to { id, type, options?, relation_data_source_id? }.",
989
- },
990
- },
991
- required: ["id", "name", "properties"],
992
- },
993
- handler: async (args, ctx) => {
994
- const payload = await notionRequest(ctx, {
995
- method: "GET",
996
- path: `/v1/data_sources/${encodeURIComponent(args.data_source_id)}`,
997
- });
998
- if (args.raw)
999
- return payload;
1000
- const properties = {};
1001
- let titleProperty = null;
1002
- for (const [name, property] of Object.entries((payload?.properties ?? {}))) {
1003
- properties[name] = projectSchemaProperty(property);
1004
- if (property?.type === "title")
1005
- titleProperty = name;
1006
- }
1007
- return {
1008
- id: payload?.id ?? null,
1009
- name: plainText(payload?.title) || (payload?.name ?? ""),
1010
- database_id: payload?.parent?.database_id ?? null,
1011
- title_property: titleProperty,
1012
- properties,
1013
- };
1014
- },
1015
- },
1016
- {
1017
- name: "integration_query_data_source",
1018
- description: "List rows in a data source with optional filtering and sorting, returning each row's properties already flattened. Requires a data_source_id, never a database_id. Narrow with the properties argument to keep results small.",
1019
- annotations: { readOnlyHint: true },
1020
- inputSchema: {
1021
- type: "object",
1022
- properties: {
1023
- data_source_id: {
1024
- type: "string",
1025
- description: "Data source id from integration_get_database or integration_search.",
1026
- },
1027
- filter: {
1028
- type: "object",
1029
- description: 'Notion filter object, passed through unchanged. Single condition: {"property":"Status","status":{"equals":"Done"}}. Compound: {"and":[...]} or {"or":[...]}. Property names must match integration_get_data_source_schema exactly.',
1030
- },
1031
- sorts: {
1032
- type: "array",
1033
- description: "Sort order, applied in sequence.",
1034
- items: {
1035
- type: "object",
1036
- properties: {
1037
- property: {
1038
- type: "string",
1039
- description: "Property name to sort by.",
1040
- },
1041
- timestamp: {
1042
- type: "string",
1043
- enum: ["created_time", "last_edited_time"],
1044
- description: "Sort by a timestamp instead of a property.",
1045
- },
1046
- direction: {
1047
- type: "string",
1048
- enum: ["ascending", "descending"],
1049
- description: "Direction for this sort.",
1050
- },
1051
- },
1052
- additionalProperties: false,
1053
- },
1054
- },
1055
- properties: PROPERTY_SELECT,
1056
- page_size: PAGE_SIZE_PROPERTY,
1057
- start_cursor: START_CURSOR_PROPERTY,
1058
- raw: RAW_PROPERTY,
1059
- },
1060
- required: ["data_source_id"],
1061
- additionalProperties: false,
1062
- },
1063
- outputSchema: listOutputSchema(PAGE_OUTPUT_SCHEMA),
1064
- handler: async (args, ctx) => {
1065
- const body = pagination(args, defaultPageSize);
1066
- if (args.filter)
1067
- body["filter"] = args.filter;
1068
- if (args.sorts)
1069
- body["sorts"] = args.sorts;
1070
- const payload = await notionRequest(ctx, {
1071
- method: "POST",
1072
- path: `/v1/data_sources/${encodeURIComponent(args.data_source_id)}/query`,
1073
- body,
1074
- });
1075
- if (args.raw)
1076
- return payload;
1077
- return mappedListEnvelope(payload, (row) => projectPage(row, args.properties));
1078
- },
1079
- },
1080
- {
1081
- name: "integration_list_users",
1082
- description: "List workspace users and bots with their ids, for assigning people properties or attributing edits. Requires the integration's user-information capability.",
1083
- annotations: { readOnlyHint: true },
1084
- inputSchema: {
1085
- type: "object",
1086
- required: [],
1087
- properties: {
1088
- page_size: PAGE_SIZE_PROPERTY,
1089
- start_cursor: START_CURSOR_PROPERTY,
1090
- },
1091
- additionalProperties: false,
1092
- },
1093
- outputSchema: listOutputSchema(USER_OUTPUT_SCHEMA),
1094
- handler: async (args, ctx) => {
1095
- const payload = await notionRequest(ctx, {
1096
- method: "GET",
1097
- path: "/v1/users",
1098
- query: pagination(args, defaultPageSize),
1099
- });
1100
- return mappedListEnvelope(payload, projectUser);
1101
- },
1102
- },
1103
- {
1104
- name: "integration_get_self",
1105
- description: "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.",
1106
- annotations: { readOnlyHint: true },
1107
- inputSchema: {
1108
- type: "object",
1109
- required: [],
1110
- properties: {},
1111
- additionalProperties: false,
1112
- },
1113
- outputSchema: {
1114
- type: "object",
1115
- properties: {
1116
- id: { type: "string" },
1117
- name: { type: ["string", "null"] },
1118
- type: { type: ["string", "null"] },
1119
- workspace_name: { type: ["string", "null"] },
1120
- },
1121
- required: ["id", "name"],
1122
- },
1123
- handler: async (_args, ctx) => {
1124
- const payload = await notionRequest(ctx, {
1125
- method: "GET",
1126
- path: "/v1/users/me",
1127
- });
1128
- return {
1129
- ...projectUser(payload),
1130
- workspace_name: payload?.bot?.workspace_name ?? null,
1131
- };
1132
- },
1133
- },
1134
- {
1135
- name: "integration_list_comments",
1136
- description: "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.",
1137
- annotations: { readOnlyHint: true },
1138
- inputSchema: {
1139
- type: "object",
1140
- properties: {
1141
- block_id: {
1142
- type: "string",
1143
- description: "Page id or block id to read comments from.",
1144
- },
1145
- page_size: PAGE_SIZE_PROPERTY,
1146
- start_cursor: START_CURSOR_PROPERTY,
1147
- raw: RAW_PROPERTY,
1148
- },
1149
- required: ["block_id"],
1150
- additionalProperties: false,
1151
- },
1152
- outputSchema: listOutputSchema(COMMENT_OUTPUT_SCHEMA),
1153
- handler: async (args, ctx) => {
1154
- const payload = await notionRequest(ctx, {
1155
- method: "GET",
1156
- path: "/v1/comments",
1157
- query: {
1158
- block_id: args.block_id,
1159
- ...pagination(args, defaultPageSize),
1160
- },
1161
- });
1162
- if (args.raw)
1163
- return payload;
1164
- return mappedListEnvelope(payload, projectComment);
1165
- },
1166
- },
1167
- // --------------------------------------------------------------- writes
1168
- {
1169
- name: "integration_create_page",
1170
- description: "Create a page, either as a child of another page or as a row in a data source. Notion has no idempotency key: a retried create makes a second page, so confirm with search before repeating one.",
1171
- annotations: { readOnlyHint: false },
1172
- inputSchema: {
1173
- // A deliberate subset of the 2026-03-11 create contract, reviewed after
1174
- // the 0.17.0 drift check (#408). Workspace-private pages, templates,
1175
- // page placement, and the expanded icon/cover forms stay out: they
1176
- // change ownership, start asynchronous content work, control ordering,
1177
- // or depend on file surfaces, none of which extend page/row authoring.
1178
- required: [],
1179
- type: "object",
1180
- properties: {
1181
- parent_page_id: {
1182
- type: "string",
1183
- description: "Create as a child page of this page. Exactly one parent id, this or parent_data_source_id.",
1184
- },
1185
- parent_data_source_id: {
1186
- type: "string",
1187
- description: "Create as a row in this data source, not a database id. Exactly one parent id, this or parent_page_id.",
1188
- },
1189
- title: { type: "string", description: "Plain-text title." },
1190
- title_property: {
1191
- type: "string",
1192
- description: 'Name of the title-typed property, from integration_get_data_source_schema. Required in practice for a data-source parent, whose title column is rarely called "title". Defaults to "title", which is the only valid key under a page parent.',
1193
- },
1194
- properties: {
1195
- type: "object",
1196
- description: 'Additional Notion property values, keyed by property name and in Notion\'s own wrapped form, e.g. {"Status":{"status":{"name":"Todo"}}}. Read integration_get_data_source_schema first.',
1197
- },
1198
- markdown: {
1199
- type: "string",
1200
- description: "Page body as Notion-flavored Markdown. Mutually exclusive with children.",
1201
- },
1202
- children: {
1203
- type: "array",
1204
- maxItems: MAX_CHILDREN_PER_REQUEST,
1205
- description: "Page body as raw Notion block objects. Mutually exclusive with markdown.",
1206
- items: { type: "object" },
1207
- },
1208
- icon: { type: "string", description: "Emoji to use as the page icon." },
1209
- },
1210
- additionalProperties: false,
1211
- },
1212
- outputSchema: PAGE_OUTPUT_SCHEMA,
1213
- handler: async (args, ctx) => {
1214
- const [parentKey, parentValue] = requireExactlyOne([
1215
- ["parent_page_id", args.parent_page_id],
1216
- ["parent_data_source_id", args.parent_data_source_id],
1217
- ], "A page needs exactly one parent, and a data source is addressed by its data_source_id from integration_get_database — never by a database_id.");
1218
- if (args.markdown !== undefined && args.children !== undefined) {
1219
- throw new ConnectorCallError("invalid_args", "Provide either markdown or children for the page body, not both.");
1220
- }
1221
- const properties = { ...args.properties };
1222
- if (args.title !== undefined) {
1223
- properties[args.title_property ?? "title"] = {
1224
- title: richText(args.title),
1225
- };
1226
- }
1227
- const body = {
1228
- parent: parentKey === "parent_page_id"
1229
- ? { type: "page_id", page_id: parentValue }
1230
- : { type: "data_source_id", data_source_id: parentValue },
1231
- properties,
1232
- };
1233
- if (args.markdown !== undefined)
1234
- body["markdown"] = args.markdown;
1235
- if (args.children !== undefined)
1236
- body["children"] = args.children;
1237
- if (args.icon !== undefined) {
1238
- body["icon"] = { type: "emoji", emoji: args.icon };
1239
- }
1240
- return projectPage(await notionRequest(ctx, {
1241
- method: "POST",
1242
- path: "/v1/pages",
1243
- body,
1244
- }));
1245
- },
1246
- },
1247
- {
1248
- name: "integration_append_blocks",
1249
- description: "Append content to the end of a page or block, or insert it at a chosen position. Appending only adds: existing blocks are never moved or replaced, and an appended block cannot be relocated later through the API.",
1250
- annotations: { readOnlyHint: false },
1251
- inputSchema: {
1252
- type: "object",
1253
- properties: {
1254
- block_id: {
1255
- type: "string",
1256
- description: "Page id or block id to append into.",
1257
- },
1258
- text: {
1259
- type: "array",
1260
- items: { type: "string" },
1261
- maxItems: MAX_CHILDREN_PER_REQUEST,
1262
- description: "Plain-text paragraphs, one block each. Mutually exclusive with children.",
1263
- },
1264
- children: {
1265
- type: "array",
1266
- items: { type: "object" },
1267
- maxItems: MAX_CHILDREN_PER_REQUEST,
1268
- description: "Raw Notion block objects, for anything paragraphs cannot express. Mutually exclusive with text.",
1269
- },
1270
- position: {
1271
- type: "string",
1272
- enum: ["end", "start", "after_block"],
1273
- description: 'Where to insert. Defaults to "end". "after_block" requires after_block_id.',
1274
- },
1275
- after_block_id: {
1276
- type: "string",
1277
- description: 'Insert directly after this block when position is "after_block".',
1278
- },
1279
- },
1280
- required: ["block_id"],
1281
- additionalProperties: false,
1282
- },
1283
- outputSchema: {
1284
- type: "object",
1285
- properties: {
1286
- appended: {
1287
- type: "integer",
1288
- description: "How many blocks were created.",
1289
- },
1290
- results: { type: "array", items: BLOCK_OUTPUT_SCHEMA },
1291
- },
1292
- required: ["appended", "results"],
1293
- },
1294
- handler: async (args, ctx) => {
1295
- const [kind, value] = requireExactlyOne([
1296
- ["text", args.text],
1297
- ["children", args.children],
1298
- ], "Use text for plain paragraphs, or children for raw Notion blocks.");
1299
- const children = kind === "text"
1300
- ? value.map((line) => ({
1301
- object: "block",
1302
- type: "paragraph",
1303
- paragraph: { rich_text: richText(line) },
1304
- }))
1305
- : value;
1306
- if (children.length === 0) {
1307
- throw new ConnectorCallError("invalid_args", "Nothing to append: provide at least one block.");
1308
- }
1309
- const body = { children };
1310
- if (args.position === "after_block") {
1311
- if (!args.after_block_id) {
1312
- throw new ConnectorCallError("invalid_args", 'position "after_block" requires after_block_id.');
1313
- }
1314
- body["position"] = {
1315
- type: "after_block",
1316
- after_block: { id: args.after_block_id },
1317
- };
1318
- }
1319
- else if (args.position) {
1320
- body["position"] = { type: args.position };
1321
- }
1322
- const payload = await notionRequest(ctx, {
1323
- method: "PATCH",
1324
- path: `/v1/blocks/${encodeURIComponent(args.block_id)}/children`,
1325
- body,
1326
- });
1327
- const results = (payload?.results ?? []).map((block) => projectBlock(block, 0));
1328
- return { appended: results.length, results };
1329
- },
1330
- },
1331
- {
1332
- name: "integration_update_page_properties",
1333
- // Deliberately no locking, templates, or `erase_content` (#409): locking
1334
- // is coordination state, templates finish asynchronously, and
1335
- // `erase_content` permanently deletes every child block. None belongs
1336
- // under an approval named for property replacement.
1337
- description: "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.",
1338
- // Replaces values that already exist: the host should say so out loud.
1339
- annotations: { readOnlyHint: false, destructiveHint: true },
1340
- inputSchema: {
1341
- type: "object",
1342
- properties: {
1343
- page_id: { type: "string", description: "Notion page id." },
1344
- title: {
1345
- type: "string",
1346
- description: "Replacement plain-text title.",
1347
- },
1348
- title_property: {
1349
- type: "string",
1350
- description: 'Name of the title-typed property, from integration_get_data_source_schema. Defaults to "title".',
1351
- },
1352
- properties: {
1353
- type: "object",
1354
- description: "Notion property values keyed by property name, in Notion's wrapped form. Read integration_get_data_source_schema for names, types, and valid option names.",
1355
- },
1356
- icon: { type: "string", description: "Replacement emoji icon." },
1357
- },
1358
- required: ["page_id"],
1359
- additionalProperties: false,
1360
- },
1361
- outputSchema: PAGE_OUTPUT_SCHEMA,
1362
- handler: async (args, ctx) => {
1363
- const properties = { ...args.properties };
1364
- if (args.title !== undefined) {
1365
- properties[args.title_property ?? "title"] = {
1366
- title: richText(args.title),
1367
- };
1368
- }
1369
- if (Object.keys(properties).length === 0 && args.icon === undefined) {
1370
- throw new ConnectorCallError("invalid_args", "Nothing to update: provide title, properties, or icon.");
1371
- }
1372
- const body = {};
1373
- if (Object.keys(properties).length > 0)
1374
- body["properties"] = properties;
1375
- if (args.icon !== undefined) {
1376
- body["icon"] = { type: "emoji", emoji: args.icon };
1377
- }
1378
- return projectPage(await notionRequest(ctx, {
1379
- method: "PATCH",
1380
- path: `/v1/pages/${encodeURIComponent(args.page_id)}`,
1381
- body,
1382
- }));
1383
- },
1384
- },
1385
- {
1386
- name: "integration_trash_page",
1387
- description: "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.",
1388
- annotations: { readOnlyHint: false, destructiveHint: true },
1389
- inputSchema: {
1390
- type: "object",
1391
- properties: {
1392
- page_id: { type: "string", description: "Notion page id." },
1393
- restore: {
1394
- type: "boolean",
1395
- description: "Restore the page out of the trash instead of moving it in.",
1396
- },
1397
- },
1398
- required: ["page_id"],
1399
- additionalProperties: false,
1400
- },
1401
- outputSchema: {
1402
- type: "object",
1403
- properties: {
1404
- id: { type: "string" },
1405
- title: { type: "string" },
1406
- in_trash: { type: "boolean" },
1407
- },
1408
- required: ["id", "in_trash"],
1409
- },
1410
- handler: async (args, ctx) => {
1411
- const payload = await notionRequest(ctx, {
1412
- method: "PATCH",
1413
- path: `/v1/pages/${encodeURIComponent(args.page_id)}`,
1414
- body: { in_trash: args.restore !== true },
1415
- });
1416
- return {
1417
- id: payload?.id ?? null,
1418
- title: pageTitle(payload?.properties),
1419
- in_trash: payload?.in_trash === true,
1420
- };
1421
- },
1422
- },
1423
- {
1424
- name: "integration_add_comment",
1425
- description: "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.",
1426
- annotations: { readOnlyHint: false },
1427
- inputSchema: {
1428
- type: "object",
1429
- properties: {
1430
- page_id: {
1431
- type: "string",
1432
- description: "Start a new discussion on this page.",
1433
- },
1434
- discussion_id: {
1435
- type: "string",
1436
- description: "Reply to this existing discussion, from integration_list_comments.",
1437
- },
1438
- text: { type: "string", description: "Comment body as plain text." },
1439
- },
1440
- required: ["text"],
1441
- additionalProperties: false,
1442
- },
1443
- outputSchema: COMMENT_OUTPUT_SCHEMA,
1444
- handler: async (args, ctx) => {
1445
- const [kind, value] = requireExactlyOne([
1446
- ["page_id", args.page_id],
1447
- ["discussion_id", args.discussion_id],
1448
- ], "Comment on a page to start a discussion, or name a discussion_id to reply to one.");
1449
- if (!String(args.text).trim()) {
1450
- throw new ConnectorCallError("invalid_args", "A comment needs non-empty text.");
1451
- }
1452
- const body = {
1453
- rich_text: richText(args.text),
1454
- ...(kind === "page_id" ? { parent: { type: "page_id", page_id: value } } : { discussion_id: value }),
1455
- };
1456
- return projectComment(await notionRequest(ctx, {
1457
- method: "POST",
1458
- path: "/v1/comments",
1459
- body,
1460
- }));
1461
- },
1462
- },
1463
- ];
1464
- }
1465
- // ---------------------------------------------------------------------------
1466
- // Guide and constructor
1467
- // ---------------------------------------------------------------------------
1468
- /**
1469
- * Marked `required` for one reason: a Notion database is a container, and the
1470
- * rows and schema live in a data source inside it. The id in a database URL is a
1471
- * database id, and passing it to `integration_query_data_source` or `integration_create_page` fails —
1472
- * a trap no input schema can teach, so the guide has to.
1473
- */
1474
- function apiUsageGuide(purpose, instructions) {
1475
- const accountInstructions = instructions?.trim();
43
+ function tokenUsageGuide(purpose, instructions) {
44
+ const { key, shared } = skill.fragments;
1476
45
  return `# Notion usage
1477
46
 
1478
- Workspace purpose: ${purpose}${skill.fragments.guide_0}${accountInstructions ? `\n## ${skill.instructionsHeading}\n\n${accountInstructions}\n` : ""}`;
47
+ Workspace purpose: ${purpose}${key}${shared}${instructionsSection(instructions)}`;
48
+ }
49
+ function oauthUsageGuide(purpose, instructions) {
50
+ const { oauth_head, oauth, shared } = skill.fragments;
51
+ return `${oauth_head}${purpose}${oauth}${shared}${instructionsSection(instructions)}`;
1479
52
  }
1480
53
  /**
1481
54
  * Reviewed in #705's provider audit against https://developers.notion.com/guides/mcp/mcp-supported-tools.
@@ -1486,6 +59,22 @@ Workspace purpose: ${purpose}${skill.fragments.guide_0}${accountInstructions ? `
1486
59
  const NOTION_MCP_CLASSIFICATION = {
1487
60
  tools: {
1488
61
  "notion-search": { "verdict": "read", "reason": "Retrieves Notion information without changing vendor state." },
62
+ "notion-ai-search": {
63
+ "verdict": "read",
64
+ "reason": "Legacy AI search, still callable by name; retrieves matching content without changing vendor state.",
65
+ },
66
+ "notion-get-self": {
67
+ "verdict": "read",
68
+ "reason": "Returns the connected workspace and user without changing vendor state.",
69
+ },
70
+ "notion-get-tool-access": {
71
+ "verdict": "read",
72
+ "reason": "Reads which tools the workspace plan allows; it grants nothing and changes nothing.",
73
+ },
74
+ "notion-download-skill": {
75
+ "verdict": "read",
76
+ "reason": "Returns an existing Skill as a signed, expiring archive URL; it changes no workspace state.",
77
+ },
1489
78
  "notion-search-skills": {
1490
79
  "verdict": "read",
1491
80
  "reason": "Retrieves Notion skills information without changing vendor state.",
@@ -1614,89 +203,77 @@ const NOTION_MCP_CLASSIFICATION = {
1614
203
  "notion-stop-session": { "verdict": "destructive", "reason": "Stops an existing session and interrupts its work." },
1615
204
  },
1616
205
  };
1617
- function mcpUsageGuide(purpose, instructions) {
1618
- const accountInstructions = instructions?.trim();
1619
- return `${skill.fragments.guide_1}${purpose}${skill.fragments.guide_2}${accountInstructions ? `\n## ${skill.instructionsHeading}\n\n${accountInstructions}\n` : ""}`;
1620
- }
1621
- function notionMcp(id, purpose, options, provider) {
1622
- const connector = remoteMcp(id, {
206
+ const COMMON = keys("title", "authScope", "purpose", "instructions", "maxResultBytes");
207
+ /** The closed options notion() accepts, selected by `auth.type`; see `assertKnownOptions`. */
208
+ const NOTION_OPTIONS = variants(["auth", "type"], {
209
+ // The never-typed keys stay out of the shape, so the walk refuses them by name.
210
+ oauth: optionsOf()({
211
+ ...COMMON,
212
+ callAdmission: CALL_ADMISSION,
213
+ auth: optionsOf()(keys("type")),
214
+ }).shape,
215
+ token: optionsOf()({
216
+ ...COMMON,
217
+ ...keys("credentialLabel", "defaultPageSize"),
218
+ auth: optionsOf()(keys("type")),
219
+ }).shape,
220
+ });
221
+ function notionOAuth(id, options, provider) {
222
+ return hostedOAuth(id, provider, {
1623
223
  url: NOTION_MCP_ENDPOINT,
1624
- ...provider.connectorOptions,
1625
224
  title: options.title ?? "Notion (MCP)",
1626
- description: `Notion's official hosted MCP interface: ${purpose}`,
1627
- auth: { type: "oauth" },
1628
- requireHttps: true,
1629
- classify: provider.classify,
225
+ description: `Notion's official hosted MCP interface: ${options.purpose}`,
1630
226
  usageGuide: {
1631
- content: mcpUsageGuide(purpose, options.instructions),
1632
- summary: "Official MCP. Live Notion schemas, object discovery, sessions, agents, attachments, and OAuth ownership.",
227
+ content: oauthUsageGuide(options.purpose, options.instructions),
228
+ summary: "Official MCP as the OAuth user. Live Notion schemas, object discovery, sessions, agents, attachments.",
1633
229
  required: true,
1634
230
  },
1635
231
  });
1636
- return connector;
1637
232
  }
1638
- function notionApi(id, purpose, options) {
233
+ function notionToken(id, options, provider) {
1639
234
  const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
1640
235
  if (!Number.isInteger(defaultPageSize) || defaultPageSize < 1 || defaultPageSize > MAX_PAGE_SIZE) {
1641
236
  throw new Error(`notion() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`);
1642
237
  }
1643
- return api(id, {
1644
- ...(options.authScope ? { authScope: options.authScope } : {}),
238
+ const rest = notionRest(defaultPageSize);
239
+ return apiConnector(id, {
240
+ ...provider.connectorOptions,
1645
241
  title: options.title ?? "Notion integration",
1646
- description: `Notion internal integration: ${purpose}`,
242
+ description: `Notion internal integration: ${options.purpose}`,
1647
243
  credential: {
1648
244
  label: options.credentialLabel ?? "Notion integration token",
1649
245
  description: "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.",
1650
246
  placeholder: "Paste the integration token",
1651
247
  },
1652
- testCredential: async (value, ctx) => {
1653
- // Notion has no token-introspection endpoint; identifying the bot is the
1654
- // cheapest call that proves the token is live.
1655
- try {
1656
- const payload = await notionRequest({ ...ctx, credential: { get: async () => value, getAll: async () => ({ value }) } }, { method: "GET", path: "/v1/users/me" });
1657
- const name = payload?.bot?.workspace_name ?? payload?.name ?? "Notion";
1658
- return { ok: true, message: `Authenticated as ${name}.` };
1659
- }
1660
- catch (error) {
1661
- return {
1662
- ok: false,
1663
- message: error instanceof ConnectorCallError ? error.message : "Notion rejected the token.",
1664
- };
1665
- }
1666
- },
248
+ testCredential: rest.testCredential,
1667
249
  callAdmission: NOTION_ADMISSION,
1668
250
  usageGuide: {
1669
- content: apiUsageGuide(purpose, options.instructions),
1670
- summary: "Internal integration identity. Exact REST queries, JSON blocks, properties, paging and bot-scoped content writes.",
251
+ content: tokenUsageGuide(options.purpose, options.instructions),
252
+ summary: "Internal integration over REST. Flattened reads, authoring helpers, and the pinned API index for the rest.",
1671
253
  required: true,
1672
254
  },
1673
- tools: buildTools(defaultPageSize),
1674
- ...(options.maxResultBytes !== undefined ? { maxResultBytes: options.maxResultBytes } : {}),
255
+ tools: rest.tools,
1675
256
  });
1676
257
  }
1677
- /** The closed options notion() accepts; see `assertKnownOptions`. */
1678
- const NOTION_OPTIONS = variants("surface", {
1679
- api: optionsOf()({
1680
- ...keys("title", "authScope", "purpose", "instructions", "maxResultBytes"),
1681
- ...keys("surface", "credentialLabel", "defaultPageSize"),
1682
- }).shape,
1683
- mcp: optionsOf()({ ...PROVIDER_COMMON, ...keys("surface") }).shape,
1684
- }, "mcp");
1685
- /** A maintained Notion connection using the selected provider interface. */
258
+ /**
259
+ * A maintained Notion connection. `auth` selects the implementation: OAuth
260
+ * reaches Notion's hosted MCP server as the authorizing user; an integration
261
+ * token reaches Connecta's REST connector to `api.notion.com` as that bot.
262
+ */
1686
263
  export const notion = defineProvider({
1687
264
  name: "notion",
1688
265
  title: "Notion",
1689
- kind: "composed",
266
+ kind: "dual",
1690
267
  readme: "Notion",
1691
- bundle: { "baselineGzip": 145300, "maxGzip": 205300 },
268
+ bundle: {
269
+ "baselineGzip": 211946,
270
+ "maxGzip": 271946,
271
+ "note": "./providers/notion remeasures at 211,946 B gzip (#801), against a 145,300 B baseline: auth now selects the implementation, so the entry carries the hosted MCP client and Connecta's REST connector (api(), the schema validator, guarded transport, the shared REST module) plus the pinned Notion operation index (64 operations; request details to depth 4, so block payload keys validate, with 160-character top-level descriptions; 14,240 B gzip as source). The index is already small, and removing eight one-operation named tools offset part of the shared module, so shrinking details further could not bring the entry under the old cap. The cap uses the existing baseline + 60,000 B policy.",
272
+ },
1692
273
  skill,
1693
274
  options: NOTION_OPTIONS,
1694
275
  classify: NOTION_MCP_CLASSIFICATION,
1695
- create: notionConnector,
276
+ create: byAuth({ oauth: notionOAuth, token: notionToken }),
1696
277
  });
1697
- function notionConnector(id, options, provider) {
1698
- const purpose = options.purpose.trim();
1699
- return options.surface === "api" ? notionApi(id, purpose, options) : notionMcp(id, purpose, options, provider);
1700
- }
1701
278
  /** @deprecated Read `notion.definition.classify` instead. Kept for existing imports. */
1702
279
  export const NOTION_MCP_VETTED_CATALOG = reviewedCatalog(notion.definition.classify, 'defineProvider("notion")');