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