@zackbart/connecta 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +126 -134
- package/bin/connecta.mjs +13 -2
- package/bin/version.mjs +1 -1
- package/dist/argument-redaction.js +230 -0
- package/dist/auth-elicitation.js +14 -9
- package/dist/authorization-handoff.js +111 -0
- package/dist/call-recovery.js +25 -0
- package/dist/catalog-cache.js +2 -2
- package/dist/catalog-service.js +103 -63
- package/dist/config-schema.d.ts +14 -7
- package/dist/config-schema.js +29 -10
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -7
- package/dist/connectors/guarded-fetch.d.ts +9 -1
- package/dist/connectors/guarded-fetch.js +17 -5
- package/dist/connectors/negotiation-cache.js +1 -1
- package/dist/downstream-input-context.js +9 -0
- package/dist/downstream-input.js +13 -11
- package/dist/errors.d.ts +38 -12
- package/dist/errors.js +19 -9
- package/dist/execute.js +375 -43
- package/dist/executor-result.js +11 -9
- package/dist/executors/quickjs-child.js +12 -5
- package/dist/executors/quickjs-protocol.js +5 -6
- package/dist/executors/quickjs-runtime.js +6 -1
- package/dist/executors/quickjs.js +20 -4
- package/dist/guest-runtime.js +52 -2
- package/dist/guest-types.d.ts +47 -5
- package/dist/index.d.ts +1 -1
- package/dist/invocation.js +72 -14
- package/dist/meta-output.js +0 -15
- package/dist/meta-tools.js +166 -95
- package/dist/oauth-handoff.js +88 -11
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.d.ts +9 -0
- package/dist/operator-ui/view.js +11 -0
- package/dist/program-result.js +5 -0
- package/dist/program-writes.js +7 -48
- package/dist/provider.d.ts +8 -6
- package/dist/provider.js +3 -4
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/_shared/request-token.js +83 -0
- package/dist/providers/_shared/rest/dispatch.js +41 -0
- package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
- package/dist/providers/_shared/rest/operation-index.js +527 -0
- package/dist/providers/_shared/rest/tools.d.ts +141 -0
- package/dist/providers/_shared/rest/tools.js +569 -0
- package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
- package/dist/providers/_shared/rest/value-safety.js +580 -0
- package/dist/providers/cloudflare/graphql.js +385 -0
- package/dist/providers/cloudflare/index.d.ts +47 -51
- package/dist/providers/cloudflare/index.js +188 -1997
- package/dist/providers/cloudflare/openapi.generated.js +3347 -0
- package/dist/providers/cloudflare/rest.d.ts +37 -0
- package/dist/providers/cloudflare/rest.js +1056 -0
- package/dist/providers/cloudflare/skill.generated.js +7 -4
- package/dist/providers/cloudflare/value-safety.js +1247 -0
- package/dist/providers/infisical/index.d.ts +7 -0
- package/dist/providers/infisical/index.js +713 -0
- package/dist/providers/infisical/skill.generated.js +8 -0
- package/dist/providers/notion/index.d.ts +35 -31
- package/dist/providers/notion/index.js +60 -1499
- package/dist/providers/notion/openapi.generated.js +75 -0
- package/dist/providers/notion/rest.d.ts +24 -0
- package/dist/providers/notion/rest.js +1067 -0
- package/dist/providers/notion/skill.generated.js +5 -4
- package/dist/providers/notion/value-safety.js +31 -0
- package/dist/providers/stripe/index.d.ts +26 -20
- package/dist/providers/stripe/index.js +103 -130
- package/dist/providers/stripe/openapi.generated.js +649 -0
- package/dist/providers/stripe/rest.d.ts +14 -0
- package/dist/providers/stripe/rest.js +395 -0
- package/dist/providers/stripe/skill.generated.js +7 -5
- package/dist/providers/stripe/value-safety.js +705 -0
- package/dist/providers/vercel/index.d.ts +31 -22
- package/dist/providers/vercel/index.js +96 -853
- package/dist/providers/vercel/openapi.generated.js +456 -0
- package/dist/providers/vercel/rest.d.ts +20 -0
- package/dist/providers/vercel/rest.js +1002 -0
- package/dist/providers/vercel/skill.generated.js +4 -5
- package/dist/providers/vercel/value-safety.js +328 -0
- package/dist/registry.d.ts +8 -6
- package/dist/registry.js +134 -61
- package/dist/resource-read.js +4 -1
- package/dist/routes/mcp.js +11 -2
- package/dist/sent-secrets.d.ts +54 -13
- package/dist/sent-secrets.js +770 -231
- package/dist/skills.js +26 -16
- package/dist/sqlite.js +21 -6
- package/dist/storage/keys.js +20 -5
- package/dist/storage/memory.js +9 -4
- package/dist/storage/sql.js +18 -9
- package/dist/types.d.ts +17 -3
- package/dist/usage-guide.js +49 -40
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +2 -1
- package/dist/write-outcome.js +47 -0
- package/documentation/README.md +64 -0
- package/documentation/architecture.md +241 -97
- package/documentation/auth.md +138 -5
- package/documentation/code-mode.md +224 -107
- package/documentation/deploying.md +146 -0
- package/documentation/integrating.md +170 -0
- package/documentation/meta-tools.md +142 -30
- package/documentation/operating.md +135 -0
- package/documentation/operator-tests.md +4 -0
- package/documentation/operator-ui.md +132 -0
- package/documentation/provider-auth-migration.md +307 -0
- package/documentation/provider-migration-0.29.md +22 -5
- package/examples/worker/README.md +9 -2
- package/package.json +9 -2
- package/templates/node/README.md +9 -2
- package/templates/node/package.json +1 -1
- package/dist/providers/vercel/mcp-ownership.js +0 -212
|
@@ -1,47 +1,15 @@
|
|
|
1
1
|
import { skill } from "./skill.generated.js";
|
|
2
|
-
import { apiConnector
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
export
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
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
|
-
|
|
135
|
-
|
|
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}${
|
|
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.
|
|
@@ -1614,89 +187,77 @@ const NOTION_MCP_CLASSIFICATION = {
|
|
|
1614
187
|
"notion-stop-session": { "verdict": "destructive", "reason": "Stops an existing session and interrupts its work." },
|
|
1615
188
|
},
|
|
1616
189
|
};
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
190
|
+
const COMMON = keys("title", "authScope", "purpose", "instructions", "maxResultBytes");
|
|
191
|
+
/** The closed options notion() accepts, selected by `auth.type`; see `assertKnownOptions`. */
|
|
192
|
+
const NOTION_OPTIONS = variants(["auth", "type"], {
|
|
193
|
+
// The never-typed keys stay out of the shape, so the walk refuses them by name.
|
|
194
|
+
oauth: optionsOf()({
|
|
195
|
+
...COMMON,
|
|
196
|
+
callAdmission: CALL_ADMISSION,
|
|
197
|
+
auth: optionsOf()(keys("type")),
|
|
198
|
+
}).shape,
|
|
199
|
+
token: optionsOf()({
|
|
200
|
+
...COMMON,
|
|
201
|
+
...keys("credentialLabel", "defaultPageSize"),
|
|
202
|
+
auth: optionsOf()(keys("type")),
|
|
203
|
+
}).shape,
|
|
204
|
+
});
|
|
205
|
+
function notionOAuth(id, options, provider) {
|
|
206
|
+
return hostedOAuth(id, provider, {
|
|
1623
207
|
url: NOTION_MCP_ENDPOINT,
|
|
1624
|
-
...provider.connectorOptions,
|
|
1625
208
|
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,
|
|
209
|
+
description: `Notion's official hosted MCP interface: ${options.purpose}`,
|
|
1630
210
|
usageGuide: {
|
|
1631
|
-
content:
|
|
1632
|
-
summary: "Official MCP. Live Notion schemas, object discovery, sessions, agents, attachments
|
|
211
|
+
content: oauthUsageGuide(options.purpose, options.instructions),
|
|
212
|
+
summary: "Official MCP as the OAuth user. Live Notion schemas, object discovery, sessions, agents, attachments.",
|
|
1633
213
|
required: true,
|
|
1634
214
|
},
|
|
1635
215
|
});
|
|
1636
|
-
return connector;
|
|
1637
216
|
}
|
|
1638
|
-
function
|
|
217
|
+
function notionToken(id, options, provider) {
|
|
1639
218
|
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
|
|
1640
219
|
if (!Number.isInteger(defaultPageSize) || defaultPageSize < 1 || defaultPageSize > MAX_PAGE_SIZE) {
|
|
1641
220
|
throw new Error(`notion() defaultPageSize must be a whole number between 1 and ${MAX_PAGE_SIZE}.`);
|
|
1642
221
|
}
|
|
1643
|
-
|
|
1644
|
-
|
|
222
|
+
const rest = notionRest(defaultPageSize);
|
|
223
|
+
return apiConnector(id, {
|
|
224
|
+
...provider.connectorOptions,
|
|
1645
225
|
title: options.title ?? "Notion integration",
|
|
1646
|
-
description: `Notion internal integration: ${purpose}`,
|
|
226
|
+
description: `Notion internal integration: ${options.purpose}`,
|
|
1647
227
|
credential: {
|
|
1648
228
|
label: options.credentialLabel ?? "Notion integration token",
|
|
1649
229
|
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
230
|
placeholder: "Paste the integration token",
|
|
1651
231
|
},
|
|
1652
|
-
testCredential:
|
|
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
|
-
},
|
|
232
|
+
testCredential: rest.testCredential,
|
|
1667
233
|
callAdmission: NOTION_ADMISSION,
|
|
1668
234
|
usageGuide: {
|
|
1669
|
-
content:
|
|
1670
|
-
summary: "Internal integration
|
|
235
|
+
content: tokenUsageGuide(options.purpose, options.instructions),
|
|
236
|
+
summary: "Internal integration over REST. Flattened reads, authoring helpers, and the pinned API index for the rest.",
|
|
1671
237
|
required: true,
|
|
1672
238
|
},
|
|
1673
|
-
tools:
|
|
1674
|
-
...(options.maxResultBytes !== undefined ? { maxResultBytes: options.maxResultBytes } : {}),
|
|
239
|
+
tools: rest.tools,
|
|
1675
240
|
});
|
|
1676
241
|
}
|
|
1677
|
-
/**
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
}).shape,
|
|
1683
|
-
mcp: optionsOf()({ ...PROVIDER_COMMON, ...keys("surface") }).shape,
|
|
1684
|
-
}, "mcp");
|
|
1685
|
-
/** A maintained Notion connection using the selected provider interface. */
|
|
242
|
+
/**
|
|
243
|
+
* A maintained Notion connection. `auth` selects the implementation: OAuth
|
|
244
|
+
* reaches Notion's hosted MCP server as the authorizing user; an integration
|
|
245
|
+
* token reaches Connecta's REST connector to `api.notion.com` as that bot.
|
|
246
|
+
*/
|
|
1686
247
|
export const notion = defineProvider({
|
|
1687
248
|
name: "notion",
|
|
1688
249
|
title: "Notion",
|
|
1689
|
-
kind: "
|
|
250
|
+
kind: "dual",
|
|
1690
251
|
readme: "Notion",
|
|
1691
|
-
bundle: {
|
|
252
|
+
bundle: {
|
|
253
|
+
"baselineGzip": 211946,
|
|
254
|
+
"maxGzip": 271946,
|
|
255
|
+
"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.",
|
|
256
|
+
},
|
|
1692
257
|
skill,
|
|
1693
258
|
options: NOTION_OPTIONS,
|
|
1694
259
|
classify: NOTION_MCP_CLASSIFICATION,
|
|
1695
|
-
create:
|
|
260
|
+
create: byAuth({ oauth: notionOAuth, token: notionToken }),
|
|
1696
261
|
});
|
|
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
262
|
/** @deprecated Read `notion.definition.classify` instead. Kept for existing imports. */
|
|
1702
263
|
export const NOTION_MCP_VETTED_CATALOG = reviewedCatalog(notion.definition.classify, 'defineProvider("notion")');
|