@zackbart/connecta 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +73 -4
  2. package/dist/catalog-service.d.ts +16 -13
  3. package/dist/catalog-service.js +106 -115
  4. package/dist/catalog.js +29 -46
  5. package/dist/connector-scope.js +2 -7
  6. package/dist/connectors/api.d.ts +4 -16
  7. package/dist/connectors/api.js +19 -46
  8. package/dist/connectors/guarded-fetch.d.ts +9 -23
  9. package/dist/connectors/guarded-fetch.js +38 -76
  10. package/dist/connectors/remote-mcp.js +36 -79
  11. package/dist/errors.d.ts +6 -27
  12. package/dist/errors.js +8 -5
  13. package/dist/execute.d.ts +24 -22
  14. package/dist/execute.js +98 -145
  15. package/dist/executor-result.d.ts +1 -0
  16. package/dist/executor-result.js +4 -11
  17. package/dist/executors/quickjs-child.js +1 -3
  18. package/dist/executors/quickjs-runtime.js +1 -3
  19. package/dist/executors/quickjs.js +1 -3
  20. package/dist/index.js +27 -57
  21. package/dist/invocation.js +113 -183
  22. package/dist/meta-tools.d.ts +15 -28
  23. package/dist/meta-tools.js +33 -89
  24. package/dist/providers/cloudflare.d.ts +2 -18
  25. package/dist/providers/cloudflare.js +1460 -2451
  26. package/dist/providers/linear.d.ts +4 -41
  27. package/dist/providers/linear.js +8 -39
  28. package/dist/providers/mixpanel.d.ts +3 -25
  29. package/dist/providers/mixpanel.js +7 -22
  30. package/dist/providers/notion.d.ts +1 -15
  31. package/dist/providers/notion.js +44 -173
  32. package/dist/providers/revenuecat.d.ts +4 -57
  33. package/dist/providers/revenuecat.js +10 -93
  34. package/dist/providers/stripe.d.ts +1 -12
  35. package/dist/providers/stripe.js +7 -45
  36. package/dist/registry.d.ts +9 -34
  37. package/dist/registry.js +9 -103
  38. package/dist/routes/mcp.js +1 -1
  39. package/dist/routes/oauth.js +3 -3
  40. package/dist/routes/shared.d.ts +15 -15
  41. package/dist/routes/shared.js +1 -3
  42. package/dist/timeout.d.ts +8 -7
  43. package/dist/timeout.js +47 -38
  44. package/dist/types.d.ts +3 -3
  45. package/dist/ui.d.ts +1 -25
  46. package/dist/ui.js +18 -45
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/documentation/call-admission.md +1 -1
  50. package/documentation/cloudflare.md +1 -1
  51. package/documentation/code-mode.md +7 -7
  52. package/documentation/connectors.md +24 -1
  53. package/documentation/linear.md +1 -1
  54. package/documentation/mixpanel.md +1 -1
  55. package/documentation/notion.md +1 -1
  56. package/documentation/operations.md +18 -14
  57. package/documentation/provider-conventions.md +1 -1
  58. package/documentation/revenuecat.md +1 -1
  59. package/documentation/stripe.md +1 -1
  60. package/documentation/upgrading.md +13 -4
  61. package/ethos.md +75 -121
  62. package/package.json +3 -4
  63. package/templates/node/package.json +1 -1
  64. package/documentation/code-first-exploration.md +0 -292
  65. package/documentation/mcp-2026-07-28.md +0 -46
  66. package/documentation/mcp-ui-design.md +0 -382
  67. package/documentation/program-ui-read-calls.md +0 -213
  68. package/documentation/provider-audit.md +0 -198
  69. package/documentation/rich-output-design.md +0 -211
@@ -17,55 +17,18 @@ export interface LinearOptions {
17
17
  title?: string;
18
18
  /** Which workspace this is and what decisions it answers. */
19
19
  purpose: string;
20
- /**
21
- * Endpoint selection. Required, and deliberately undefaulted.
22
- *
23
- * `"read-only"` binds the connection to Linear's read-only endpoint, whose
24
- * token is scope-limited downstream — a stronger guarantee than any
25
- * annotation Connecta applies. `"read-write"` reaches the full API.
26
- *
27
- * Neither is a safe default. Defaulting to `"read-write"` hands a deployment
28
- * write access it never asked for, and defaulting to `"read-only"` turns a
29
- * deployment that does write into one whose every write fails at Linear —
30
- * at runtime, where no agent can repair it. So the operator declares it, and
31
- * a deployment that forgot fails here instead
32
- * ([#342](https://github.com/zackbart/connecta/issues/342)).
33
- */
20
+ /** Required endpoint selection; see `documentation/linear.md`. */
34
21
  access: LinearAccess;
35
- /**
36
- * OAuth by default. A Linear personal API key works either as a literal
37
- * header or as an operator-managed credential (`{ type: "credential" }`).
38
- * Linear's MCP documentation asks for `Authorization: Bearer <yourtoken>`
39
- * for both API keys and OAuth tokens (https://linear.app/docs/mcp), which is
40
- * the framing default, so the credential shape needs no `scheme` of its own.
41
- */
22
+ /** OAuth or a personal API key; see `documentation/linear.md`. */
42
23
  auth?: RemoteMcpAuth;
43
24
  /** Workspace-specific conventions appended to the maintained provider guide. */
44
25
  instructions?: string;
45
26
  /** Connector-specific inline result limit; omit to inherit the deployment. */
46
27
  maxResultBytes?: number;
47
- /**
48
- * Optional per-runtime call-admission policy. Deliberately not defaulted:
49
- * Linear documents no MCP-specific limit, and the underlying API limit is
50
- * per user per hour, varies by credential type, and is raised dynamically
51
- * for workspace-level OAuth apps. A hardcoded per-runtime ceiling would
52
- * either throttle a healthy deployment or fail to protect a busy one, so
53
- * the number stays with the operator who knows the workspace.
54
- */
28
+ /** Optional per-runtime policy; see `documentation/linear.md#rate-limits`. */
55
29
  callAdmission?: ConnectorCallAdmissionPolicy;
56
30
  }
57
- /**
58
- * The manifest this release reviewed: both lists in one place, which is what
59
- * makes the classification the connector applies and the drift check that runs
60
- * beside it the same fact (P13). No schema digests yet — no release has read
61
- * Linear's live schemas and written them down, and an invented digest would
62
- * report a change that never happened. `npm run drift:check -- --record` reads
63
- * them from a live workspace and prints the block to paste in
64
- * ([#351](https://github.com/zackbart/connecta/issues/351)).
65
- *
66
- * Exported because the maintainer-run check compares against this manifest and
67
- * *names* what moved, which the runtime check deliberately cannot.
68
- */
31
+ /** Release-reviewed manifest; see provider conventions P5 and P13. */
69
32
  export declare const LINEAR_VETTED_CATALOG: import("../catalog-drift.js").VettedCatalog;
70
33
  /** A maintained Linear hosted-MCP connection. */
71
34
  export declare function linear(id: string, options: LinearOptions): Connector;
@@ -1,5 +1,6 @@
1
1
  import { remoteMcp, withCredentialDefaults, } from "../connectors/remote-mcp.js";
2
2
  import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
3
+ import { defined } from "../connectors/api.js";
3
4
  /**
4
5
  * Linear publishes two hosted endpoints. `read-only` is not a client-side
5
6
  * filter: it advertises the `read` scope alone, so the token minted for it
@@ -10,16 +11,7 @@ export const LINEAR_MCP_ENDPOINTS = {
10
11
  "read-write": "https://mcp.linear.app/mcp",
11
12
  "read-only": "https://mcp.linear.app/mcp/readonly",
12
13
  };
13
- /**
14
- * Tools whose contract is observational rather than mutating.
15
- *
16
- * Linear's hosted catalog is not a fixed set — it varies by workspace plan and
17
- * enabled features (customer requests, releases, and code review are gated),
18
- * so this list is a superset of what any one workspace lists. A name here that
19
- * the workspace never returns costs nothing; a real read missing from it merely
20
- * fails closed. Only a write mistakenly listed here would be a safety bug,
21
- * which is why ambiguous helpers stay out.
22
- */
14
+ /** Reviewed reads; see `documentation/linear.md` and provider convention P5. */
23
15
  const READ_ONLY_TOOLS = new Set([
24
16
  // Issues
25
17
  "list_issues",
@@ -77,17 +69,7 @@ const READ_ONLY_TOOLS = new Set([
77
69
  // than argues.
78
70
  "extract_images",
79
71
  ]);
80
- /**
81
- * The maintained write catalog. `"destructive"` tools modify or remove state
82
- * that already exists; `"additive"` ones only bring something new into being.
83
- * Both leave the read-only path — the distinction only decides whether the
84
- * connection asserts `destructiveHint`, which shapes the host's approval copy.
85
- *
86
- * Linear's `save_*` tools are upserts: passing an existing record's id updates
87
- * it in place. An upsert can therefore overwrite, so every `save_*` is
88
- * destructive even though some calls only create. The `create_*_label` tools
89
- * are the genuine creates.
90
- */
72
+ /** Reviewed writes; `save_*` upsert rationale lives in `documentation/linear.md`. */
91
73
  const WRITE_TOOLS = new Map([
92
74
  // Issues
93
75
  ["save_issue", "destructive"],
@@ -127,18 +109,7 @@ const WRITE_TOOLS = new Map([
127
109
  ["save_customer_need", "destructive"],
128
110
  ["delete_customer_need", "destructive"],
129
111
  ]);
130
- /**
131
- * The manifest this release reviewed: both lists in one place, which is what
132
- * makes the classification the connector applies and the drift check that runs
133
- * beside it the same fact (P13). No schema digests yet — no release has read
134
- * Linear's live schemas and written them down, and an invented digest would
135
- * report a change that never happened. `npm run drift:check -- --record` reads
136
- * them from a live workspace and prints the block to paste in
137
- * ([#351](https://github.com/zackbart/connecta/issues/351)).
138
- *
139
- * Exported because the maintainer-run check compares against this manifest and
140
- * *names* what moved, which the runtime check deliberately cannot.
141
- */
112
+ /** Release-reviewed manifest; see provider conventions P5 and P13. */
142
113
  export const LINEAR_VETTED_CATALOG = vettedCatalog({
143
114
  reads: READ_ONLY_TOOLS,
144
115
  writes: WRITE_TOOLS,
@@ -214,12 +185,10 @@ export function linear(id, options) {
214
185
  // guide adds cross-tool sequence advice that is worth reading before a
215
186
  // write, not worth loading before every read.
216
187
  },
217
- ...(options.callAdmission !== undefined
218
- ? { callAdmission: options.callAdmission }
219
- : {}),
220
- ...(options.maxResultBytes !== undefined
221
- ? { maxResultBytes: options.maxResultBytes }
222
- : {}),
188
+ ...defined({
189
+ callAdmission: options.callAdmission,
190
+ maxResultBytes: options.maxResultBytes,
191
+ }),
223
192
  });
224
193
  return withVettedCatalog(connector, LINEAR_VETTED_CATALOG);
225
194
  }
@@ -11,12 +11,7 @@ export interface MixpanelOptions {
11
11
  title?: string;
12
12
  /** Who should use this account and for what decisions. */
13
13
  purpose: string;
14
- /**
15
- * Mixpanel data residency region. Defaults to `"us"`, which is where a
16
- * project lives unless it was explicitly created in the EU or India
17
- * residency — the other two are opt-in, so `"us"` is the honest default
18
- * rather than a convenient one.
19
- */
14
+ /** Data residency region; see `documentation/mixpanel.md`. */
20
15
  region?: MixpanelRegion;
21
16
  /**
22
17
  * OAuth by default; static headers support Mixpanel service accounts, and
@@ -28,27 +23,10 @@ export interface MixpanelOptions {
28
23
  instructions?: string;
29
24
  /** Connector-specific inline result limit; omit to inherit the deployment. */
30
25
  maxResultBytes?: number;
31
- /**
32
- * Optional per-runtime call-admission policy. Deliberately not defaulted:
33
- * Mixpanel meters its MCP server per user per hour, and a per-runtime
34
- * counter cannot approximate a per-user quota — one runtime serving several
35
- * users under-counts, and several runtimes sharing one user over-counts.
36
- * A hardcoded ceiling would therefore either throttle a healthy deployment
37
- * or fail to protect a busy one, so the number stays with the operator who
38
- * knows the account.
39
- */
26
+ /** Optional per-runtime policy; see `documentation/mixpanel.md#rate-limits`. */
40
27
  callAdmission?: ConnectorCallAdmissionPolicy;
41
28
  }
42
- /**
43
- * The manifest this release reviewed: both lists in one place, which is what
44
- * makes the classification the connector applies and the drift check that runs
45
- * beside it the same fact (P13). The 2026-08-13 read-only audit recorded all
46
- * 63 live US schemas, so a later change is named by the maintainer-run drift
47
- * check rather than rediscovered in production (#395).
48
- *
49
- * Exported because the maintainer-run check compares against this manifest and
50
- * *names* what moved, which the runtime check deliberately cannot.
51
- */
29
+ /** Release-reviewed manifest; see provider conventions P5 and P13. */
52
30
  export declare const MIXPANEL_VETTED_CATALOG: import("../catalog-drift.js").VettedCatalog;
53
31
  /** A maintained Mixpanel hosted-MCP connection. */
54
32
  export declare function mixpanel(id: string, options: MixpanelOptions): Connector;
@@ -1,5 +1,6 @@
1
1
  import { remoteMcp, withCredentialDefaults, } from "../connectors/remote-mcp.js";
2
2
  import { vettedCatalog, withVettedCatalog } from "../catalog-drift.js";
3
+ import { defined } from "../connectors/api.js";
3
4
  export const MIXPANEL_MCP_ENDPOINTS = {
4
5
  us: "https://mcp.mixpanel.com/mcp",
5
6
  eu: "https://mcp-eu.mixpanel.com/mcp",
@@ -43,12 +44,7 @@ const READ_ONLY_TOOLS = new Set([
43
44
  "Get-Feature-Flag-Setup-Guidance",
44
45
  "Get-Feature-Flag-Lifecycle-Guidance",
45
46
  ]);
46
- /**
47
- * The maintained write catalog. `"destructive"` tools modify or remove state
48
- * that already exists; `"additive"` ones only bring something new into being.
49
- * Both leave the read-only path — the distinction only decides whether the
50
- * connection asserts `destructiveHint`, which shapes the host's approval copy.
51
- */
47
+ /** Reviewed writes; see provider convention P5. */
52
48
  const WRITE_TOOLS = new Map([
53
49
  ["Create-Dashboard", "additive"],
54
50
  ["Update-Dashboard", "destructive"],
@@ -145,16 +141,7 @@ const MIXPANEL_SCHEMA_DIGESTS = {
145
141
  "Update-Lookup-Table": "sha256:ef28f1ec9c9484a7a53b5e2b6659e70f79a8e71e55bda17025aa4ab3c23b6a63",
146
142
  "Update-Metric": "sha256:739bb6abdab19282afd4a6644a96183daabe9098a5322c44a77b840608a5ee9d",
147
143
  };
148
- /**
149
- * The manifest this release reviewed: both lists in one place, which is what
150
- * makes the classification the connector applies and the drift check that runs
151
- * beside it the same fact (P13). The 2026-08-13 read-only audit recorded all
152
- * 63 live US schemas, so a later change is named by the maintainer-run drift
153
- * check rather than rediscovered in production (#395).
154
- *
155
- * Exported because the maintainer-run check compares against this manifest and
156
- * *names* what moved, which the runtime check deliberately cannot.
157
- */
144
+ /** Release-reviewed manifest; see provider conventions P5 and P13. */
158
145
  export const MIXPANEL_VETTED_CATALOG = vettedCatalog({
159
146
  reads: READ_ONLY_TOOLS,
160
147
  writes: WRITE_TOOLS,
@@ -237,12 +224,10 @@ export function mixpanel(id, options) {
237
224
  // carries the project-then-context sequence, which is worth reading
238
225
  // before an analysis rather than before every call.
239
226
  },
240
- ...(options.callAdmission !== undefined
241
- ? { callAdmission: options.callAdmission }
242
- : {}),
243
- ...(options.maxResultBytes !== undefined
244
- ? { maxResultBytes: options.maxResultBytes }
245
- : {}),
227
+ ...defined({
228
+ callAdmission: options.callAdmission,
229
+ maxResultBytes: options.maxResultBytes,
230
+ }),
246
231
  });
247
232
  return withVettedCatalog(connector, MIXPANEL_VETTED_CATALOG);
248
233
  }
@@ -1,21 +1,7 @@
1
1
  import type { Connector } from "../types.js";
2
2
  /** Notion's REST origin. Every tool below speaks to exactly this host. */
3
3
  export declare const NOTION_API_BASE_URL = "https://api.notion.com";
4
- /**
5
- * The API version this connection is written against, pinned deliberately.
6
- *
7
- * Notion's versions are date-named and old ones keep working indefinitely, so
8
- * a version override would look harmless. It is not: `2026-03-11` is the
9
- * version in which databases split into data sources, `archived` became
10
- * `in_trash`, and block append took a `position` object instead of `after`.
11
- * Every projection and every write body below assumes those shapes. A
12
- * deployment that pinned an older version would get silently wrong results
13
- * rather than a loud failure, so the option does not exist.
14
- *
15
- * Notion also ships additive changes to *every* version at once, which is why
16
- * the projections below degrade gracefully on unknown property and block types
17
- * rather than switching exhaustively.
18
- */
4
+ /** See documentation/notion.md#the-pinned-api-version. */
19
5
  export declare const NOTION_API_VERSION = "2026-03-11";
20
6
  export interface NotionOptions {
21
7
  /** Human-readable display name; defaults to "Notion". */
@@ -1,23 +1,9 @@
1
1
  import { api } from "../connectors/api.js";
2
- import { guardedFetch } from "../connectors/guarded-fetch.js";
2
+ import { guardedFetch, retryAfterMs, } from "../connectors/guarded-fetch.js";
3
3
  import { ConnectorCallError } from "../errors.js";
4
4
  /** Notion's REST origin. Every tool below speaks to exactly this host. */
5
5
  export const NOTION_API_BASE_URL = "https://api.notion.com";
6
- /**
7
- * The API version this connection is written against, pinned deliberately.
8
- *
9
- * Notion's versions are date-named and old ones keep working indefinitely, so
10
- * a version override would look harmless. It is not: `2026-03-11` is the
11
- * version in which databases split into data sources, `archived` became
12
- * `in_trash`, and block append took a `position` object instead of `after`.
13
- * Every projection and every write body below assumes those shapes. A
14
- * deployment that pinned an older version would get silently wrong results
15
- * rather than a loud failure, so the option does not exist.
16
- *
17
- * Notion also ships additive changes to *every* version at once, which is why
18
- * the projections below degrade gracefully on unknown property and block types
19
- * rather than switching exhaustively.
20
- */
6
+ /** See documentation/notion.md#the-pinned-api-version. */
21
7
  export const NOTION_API_VERSION = "2026-03-11";
22
8
  /** Notion's hard cap on `page_size` for every paginated endpoint. */
23
9
  const MAX_PAGE_SIZE = 100;
@@ -25,15 +11,7 @@ const MAX_PAGE_SIZE = 100;
25
11
  const DEFAULT_PAGE_SIZE = 25;
26
12
  /** Notion's cap on `children` per append, and on blocks per page create. */
27
13
  const MAX_CHILDREN_PER_REQUEST = 100;
28
- /**
29
- * Ceiling on downstream requests inside one `get_page_content` call.
30
- *
31
- * Recursive block traversal is the one tool here that can turn a single agent
32
- * call into unbounded provider traffic, and call admission meters *tool calls*,
33
- * not the fetches inside them. A depth-2 walk of a large page would otherwise
34
- * spend the whole rate-limit budget without ever being rejected. When the walk
35
- * hits this ceiling it stops and says so in `truncated`.
36
- */
14
+ /** See documentation/notion.md#rate-limiting. */
37
15
  const MAX_CONTENT_REQUESTS = 20;
38
16
  /**
39
17
  * The largest response this connection will read.
@@ -44,24 +22,7 @@ const MAX_CONTENT_REQUESTS = 20;
44
22
  * a budget any real read has to think about.
45
23
  */
46
24
  const NOTION_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
47
- /**
48
- * Approximates Notion's documented limit: "an average of three requests per
49
- * second, with some bursts beyond the average allowed" — expressed as a
50
- * rolling minute so short bursts pass and a sustained loop does not.
51
- *
52
- * `maxConcurrency` is the load-bearing half. A budget alone is an average, and
53
- * an averaged budget cannot stop a program from firing forty calls in the same
54
- * tick; the concurrency cap keeps a burst shaped roughly like the one Notion
55
- * documents. Neither half is a guarantee: admission meters *tool calls*, and
56
- * one admitted `get_page_content` can spend up to `MAX_CONTENT_REQUESTS`
57
- * fetches, so this bounds tool calls rather than requests. Declaring the cap is
58
- * also what makes the queue settings legal — the admission controller refuses
59
- * queue settings without a queue at construction.
60
- *
61
- * Per-runtime, like every connector budget: N Worker isolates or Node
62
- * processes serving one deployment each keep their own counter. This
63
- * approximates the provider's limit; it does not enforce it.
64
- */
25
+ /** See documentation/notion.md#rate-limiting. */
65
26
  const NOTION_ADMISSION = {
66
27
  rules: [
67
28
  {
@@ -73,49 +34,24 @@ const NOTION_ADMISSION = {
73
34
  },
74
35
  ],
75
36
  };
76
- /** `Retry-After` is documented as an integer number of seconds, in decimal. */
77
- function parseRetryAfterMs(header) {
78
- if (!header)
79
- return undefined;
80
- const seconds = Number(header.trim());
81
- if (!Number.isFinite(seconds) || seconds < 0)
82
- return undefined;
83
- return Math.trunc(seconds * 1000);
84
- }
85
- /**
86
- * Map one Notion error response onto connecta's typed failures.
87
- *
88
- * The mapping is deliberately not one-to-one. Notion's `code` values describe
89
- * what its API thinks happened; connecta's codes describe what the *caller*
90
- * should do next, and two of Notion's are easy to mistranslate:
91
- *
92
- * - `restricted_resource` (403) is not `auth_required`. The token is fine; the
93
- * integration lacks a capability or was never shared this object. Routing it
94
- * to `auth_required` would send an agent to `authorize_connector`, which
95
- * cannot fix it. It is a non-retryable call failure with instructions.
96
- * - `object_not_found` (404) is overloaded by Notion itself — it means "no
97
- * such object" *or* "not shared with this integration", and the API will not
98
- * say which. The message says both, because treating it as definitive
99
- * absence is how an agent concludes a page was deleted when it simply was
100
- * never shared.
101
- */
102
- function notionFailure(status, body, retryAfterHeader) {
37
+ /** See documentation/notion.md#typed-failures. */
38
+ function notionFailure(status, body, headers) {
103
39
  const code = typeof body?.["code"] === "string" ? body["code"] : undefined;
104
40
  const detail = typeof body?.["message"] === "string" && body["message"].trim()
105
41
  ? body["message"].trim()
106
42
  : `Notion returned HTTP ${status}.`;
107
- const retryAfterMs = parseRetryAfterMs(retryAfterHeader);
43
+ const retryAfter = retryAfterMs(headers);
108
44
  const labelled = code ? `Notion ${code}: ${detail}` : detail;
109
45
  if (status === 429) {
110
46
  const additional = body?.["additional_data"];
111
47
  const reason = additional && typeof additional === "object"
112
48
  ? additional["rate_limit_reason"]
113
49
  : undefined;
114
- return new ConnectorCallError("rate_limited", `${labelled}${typeof reason === "string" ? ` (limit: ${reason})` : ""} Notion allows roughly three requests per second per integration.`, { retryAfterMs: retryAfterMs ?? 1_000 });
50
+ return new ConnectorCallError("rate_limited", `${labelled}${typeof reason === "string" ? ` (limit: ${reason})` : ""} Notion allows roughly three requests per second per integration.`, { retryAfterMs: retryAfter ?? 1_000 });
115
51
  }
116
52
  if (status === 529) {
117
53
  // Notion documents 529 alongside 429: back off and respect Retry-After.
118
- return new ConnectorCallError("unavailable", `${labelled} Notion is overloaded; retry after the reported window.`, { retryAfterMs: retryAfterMs ?? 5_000 });
54
+ return new ConnectorCallError("unavailable", `${labelled} Notion is overloaded; retry after the reported window.`, { retryAfterMs: retryAfter ?? 5_000 });
119
55
  }
120
56
  if (status === 401) {
121
57
  return new ConnectorCallError("auth_required", `${labelled} The Notion integration token is missing or invalid — an operator must set a valid token on /credentials.`);
@@ -133,25 +69,16 @@ function notionFailure(status, body, retryAfterHeader) {
133
69
  return new ConnectorCallError("invalid_args", labelled);
134
70
  }
135
71
  if (status === 409) {
136
- return new ConnectorCallError("unavailable", `${labelled} Notion reported a write conflict; this is safe to retry.`, { retryAfterMs: retryAfterMs ?? 1_000 });
72
+ return new ConnectorCallError("unavailable", `${labelled} Notion reported a write conflict; this is safe to retry.`, { retryAfterMs: retryAfter ?? 1_000 });
137
73
  }
138
74
  if (status >= 500) {
139
- return new ConnectorCallError("unavailable", `${labelled} Notion is failing upstream.`, retryAfterMs !== undefined ? { retryAfterMs } : {});
75
+ return new ConnectorCallError("unavailable", `${labelled} Notion is failing upstream.`, retryAfter !== undefined ? { retryAfterMs: retryAfter } : {});
140
76
  }
141
77
  return new ConnectorCallError("connector_call_failed", labelled, {
142
78
  retryable: false,
143
79
  });
144
80
  }
145
- /**
146
- * The one transport every Notion tool goes through.
147
- *
148
- * URL confinement, `ctx.signal`, redirect refusal, bounded reads, and the
149
- * "could not reach the provider" normalization live in the shared helper. The
150
- * two things that cannot be shared stay here: the integration token becomes
151
- * the headers Notion accepts, and `notionFailure` decides what a status means
152
- * — which is the whole reason a generic HTTP client is the wrong shape, given
153
- * that Notion's 403 and 404 both mean something no status table would guess.
154
- */
81
+ /** See documentation/connectors.md#the-guarded-fetch-transport. */
155
82
  const send = guardedFetch({
156
83
  provider: "Notion",
157
84
  baseUrl: NOTION_API_BASE_URL,
@@ -167,36 +94,17 @@ const send = guardedFetch({
167
94
  });
168
95
  async function notionRequest(ctx, request) {
169
96
  return await send(request, ctx, async (response) => {
170
- let payload;
171
- try {
172
- // Notion answers a delete with an empty body and a gateway answers with
173
- // HTML; neither is a payload, and neither is worth a different failure.
174
- payload = (await response.json());
175
- }
176
- catch (cause) {
177
- // A transport failure is not a parse failure. The connector's byte
178
- // ceiling fires from inside this read, and swallowing it would report an
179
- // oversized response as an empty success on a 2xx.
180
- if (cause instanceof ConnectorCallError)
181
- throw cause;
182
- payload = undefined;
183
- }
97
+ const parsed = await response.jsonResult();
98
+ const payload = "value" in parsed
99
+ ? parsed.value
100
+ : undefined;
184
101
  if (!response.ok) {
185
- throw notionFailure(response.status, payload, response.headers.get("Retry-After"));
102
+ throw notionFailure(response.status, payload, response.headers);
186
103
  }
187
104
  return payload ?? {};
188
105
  });
189
106
  }
190
- // ---------------------------------------------------------------------------
191
- // Projections
192
- //
193
- // Notion's payloads are the reason this connection is hand-written. A single
194
- // page carries every property as a discriminated wrapper object, every string
195
- // as an array of rich-text runs each with its own annotations block, and every
196
- // user reference as a nested object. The projections below reduce that to what
197
- // an agent reasons about — ids, plain text, and flattened values — and each
198
- // tool that can lose information this way takes `raw: true` to opt out.
199
- // ---------------------------------------------------------------------------
107
+ // Projections: documentation/notion.md#lean-projections-and-the-raw-escape-hatch.
200
108
  /** Concatenate a rich-text array to its plain text. Safe for every variant. */
201
109
  function plainText(value) {
202
110
  if (!Array.isArray(value))
@@ -380,14 +288,7 @@ function projectPage(page, select) {
380
288
  ...(truncated.length ? { truncated_properties: truncated } : {}),
381
289
  };
382
290
  }
383
- /**
384
- * Search returns pages and data sources; both get the identity fields only.
385
- *
386
- * Deliberately omits `properties`. A 25-result search over a populated
387
- * database would otherwise return several hundred flattened property values
388
- * for results the agent is about to discard — the exact bloat this connection
389
- * exists to remove. `get_page` fetches properties for the one that matched.
390
- */
291
+ /** See documentation/notion.md#lean-projections-and-the-raw-escape-hatch. */
391
292
  function projectSearchHit(hit) {
392
293
  if (hit?.object === "data_source") {
393
294
  return {
@@ -454,12 +355,7 @@ function projectBlock(block, depth) {
454
355
  projected["icon"] = iconRef(payload?.icon);
455
356
  break;
456
357
  default:
457
- // Notion adds block types to every API version at once, so an unhandled
458
- // type is expected rather than exceptional — `meeting_notes` shipped in
459
- // the very version pinned above. A payload built around `rich_text` is
460
- // already fully represented by `text`; one that is not would otherwise
461
- // project to an empty string and lose its entire body, so it keeps the
462
- // payload verbatim. `color` alone is presentation, not content.
358
+ // Rationale: documentation/notion.md#lean-projections-and-the-raw-escape-hatch.
463
359
  if (carriesUnprojectedContent(payload))
464
360
  projected["raw"] = payload;
465
361
  break;
@@ -683,6 +579,15 @@ function listEnvelope(payload, results) {
683
579
  next_cursor: payload?.next_cursor ?? null,
684
580
  };
685
581
  }
582
+ function mappedListEnvelope(payload, project) {
583
+ return listEnvelope(payload, (payload?.results ?? []).map(project));
584
+ }
585
+ function pagination(args, defaultPageSize) {
586
+ return {
587
+ page_size: resolvePageSize(args.page_size, defaultPageSize),
588
+ ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
589
+ };
590
+ }
686
591
  /** Exactly-one-of validation, phrased so the agent knows what to send next. */
687
592
  function requireExactlyOne(provided, hint) {
688
593
  const present = provided.filter(([, value]) => value !== undefined && value !== null && value !== "");
@@ -741,13 +646,9 @@ function buildTools(defaultPageSize) {
741
646
  required: ["id", "object", "title"],
742
647
  }),
743
648
  handler: async (args, ctx) => {
744
- const body = {
745
- page_size: resolvePageSize(args.page_size, defaultPageSize),
746
- };
649
+ const body = pagination(args, defaultPageSize);
747
650
  if (args.query)
748
651
  body["query"] = args.query;
749
- if (args.start_cursor)
750
- body["start_cursor"] = args.start_cursor;
751
652
  if (args.object_type) {
752
653
  body["filter"] = { property: "object", value: args.object_type };
753
654
  }
@@ -767,7 +668,7 @@ function buildTools(defaultPageSize) {
767
668
  });
768
669
  if (args.raw)
769
670
  return payload;
770
- return listEnvelope(payload, (payload?.results ?? []).map(projectSearchHit));
671
+ return mappedListEnvelope(payload, projectSearchHit);
771
672
  },
772
673
  },
773
674
  {
@@ -826,14 +727,9 @@ function buildTools(defaultPageSize) {
826
727
  additionalProperties: false,
827
728
  },
828
729
  outputSchema: {
829
- type: "object",
730
+ ...listOutputSchema(BLOCK_OUTPUT_SCHEMA),
830
731
  properties: {
831
- results: { type: "array", items: BLOCK_OUTPUT_SCHEMA },
832
- has_more: {
833
- type: "boolean",
834
- description: "True when the top level has another page of blocks.",
835
- },
836
- next_cursor: { type: ["string", "null"] },
732
+ ...listOutputSchema(BLOCK_OUTPUT_SCHEMA)["properties"],
837
733
  truncated: {
838
734
  type: "boolean",
839
735
  description: "True when the nested walk stopped at its request ceiling. Some descendants are missing; re-read a specific block_id to continue.",
@@ -846,10 +742,7 @@ function buildTools(defaultPageSize) {
846
742
  const top = await notionRequest(ctx, {
847
743
  method: "GET",
848
744
  path: `/v1/blocks/${encodeURIComponent(args.block_id)}/children`,
849
- query: {
850
- page_size: pageSize,
851
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
852
- },
745
+ query: pagination(args, pageSize),
853
746
  });
854
747
  if (args.raw)
855
748
  return top;
@@ -927,10 +820,7 @@ function buildTools(defaultPageSize) {
927
820
  const payload = await notionRequest(ctx, {
928
821
  method: "GET",
929
822
  path: `/v1/pages/${encodeURIComponent(args.page_id)}/properties/${encodeURIComponent(args.property_id)}`,
930
- query: {
931
- page_size: resolvePageSize(args.page_size, defaultPageSize),
932
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
933
- },
823
+ query: pagination(args, defaultPageSize),
934
824
  });
935
825
  if (args.raw)
936
826
  return payload;
@@ -939,7 +829,7 @@ function buildTools(defaultPageSize) {
939
829
  // A list envelope's own `type` is the literal "property_item";
940
830
  // the property's real type sits one level down.
941
831
  type: payload?.property_item?.type ?? payload?.type ?? null,
942
- ...listEnvelope(payload, (payload?.results ?? []).map(projectPropertyItem)),
832
+ ...mappedListEnvelope(payload, projectPropertyItem),
943
833
  };
944
834
  }
945
835
  return {
@@ -1105,15 +995,11 @@ function buildTools(defaultPageSize) {
1105
995
  },
1106
996
  outputSchema: listOutputSchema(PAGE_OUTPUT_SCHEMA),
1107
997
  handler: async (args, ctx) => {
1108
- const body = {
1109
- page_size: resolvePageSize(args.page_size, defaultPageSize),
1110
- };
998
+ const body = pagination(args, defaultPageSize);
1111
999
  if (args.filter)
1112
1000
  body["filter"] = args.filter;
1113
1001
  if (args.sorts)
1114
1002
  body["sorts"] = args.sorts;
1115
- if (args.start_cursor)
1116
- body["start_cursor"] = args.start_cursor;
1117
1003
  const payload = await notionRequest(ctx, {
1118
1004
  method: "POST",
1119
1005
  path: `/v1/data_sources/${encodeURIComponent(args.data_source_id)}/query`,
@@ -1121,7 +1007,7 @@ function buildTools(defaultPageSize) {
1121
1007
  });
1122
1008
  if (args.raw)
1123
1009
  return payload;
1124
- return listEnvelope(payload, (payload?.results ?? []).map((row) => projectPage(row, args.properties)));
1010
+ return mappedListEnvelope(payload, (row) => projectPage(row, args.properties));
1125
1011
  },
1126
1012
  },
1127
1013
  {
@@ -1142,12 +1028,9 @@ function buildTools(defaultPageSize) {
1142
1028
  const payload = await notionRequest(ctx, {
1143
1029
  method: "GET",
1144
1030
  path: "/v1/users",
1145
- query: {
1146
- page_size: resolvePageSize(args.page_size, defaultPageSize),
1147
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
1148
- },
1031
+ query: pagination(args, defaultPageSize),
1149
1032
  });
1150
- return listEnvelope(payload, (payload?.results ?? []).map(projectUser));
1033
+ return mappedListEnvelope(payload, projectUser);
1151
1034
  },
1152
1035
  },
1153
1036
  {
@@ -1206,13 +1089,12 @@ function buildTools(defaultPageSize) {
1206
1089
  path: "/v1/comments",
1207
1090
  query: {
1208
1091
  block_id: args.block_id,
1209
- page_size: resolvePageSize(args.page_size, defaultPageSize),
1210
- ...(args.start_cursor ? { start_cursor: args.start_cursor } : {}),
1092
+ ...pagination(args, defaultPageSize),
1211
1093
  },
1212
1094
  });
1213
1095
  if (args.raw)
1214
1096
  return payload;
1215
- return listEnvelope(payload, (payload?.results ?? []).map(projectComment));
1097
+ return mappedListEnvelope(payload, projectComment);
1216
1098
  },
1217
1099
  },
1218
1100
  // --------------------------------------------------------------- writes
@@ -1221,12 +1103,7 @@ function buildTools(defaultPageSize) {
1221
1103
  description: "Create a page, either as a child of another page or as a row in a data source. Notion has no idempotency key: a retried create makes a second page, so confirm with search before repeating one.",
1222
1104
  annotations: { readOnlyHint: false },
1223
1105
  inputSchema: {
1224
- // Empty rather than absent: a parent is required, but *which* parent is
1225
- // an exclusive choice a plain-object `required` list cannot express,
1226
- // and the top-level `anyOf` that could would cost the tool its
1227
- // `inputKeys` in discovery. The choice is stated in both parent
1228
- // descriptions and enforced locally as `invalid_args` before any round
1229
- // trip ([#342](https://github.com/zackbart/connecta/issues/342)).
1106
+ // See documentation/notion.md#what-this-connection-does-not-do.
1230
1107
  required: [],
1231
1108
  type: "object",
1232
1109
  properties: {
@@ -1515,13 +1392,7 @@ function buildTools(defaultPageSize) {
1515
1392
  // ---------------------------------------------------------------------------
1516
1393
  // Guide and constructor
1517
1394
  // ---------------------------------------------------------------------------
1518
- /**
1519
- * Only what the schemas cannot carry.
1520
- *
1521
- * Marked `required` because the database/data-source split is a mandatory
1522
- * cross-tool sequence, not advice: a caller who reaches for `query_data_source`
1523
- * with the id in a Notion URL gets a failure no schema warned it about.
1524
- */
1395
+ /** See documentation/notion.md#databases-contain-data-sources. */
1525
1396
  function usageGuide(purpose, instructions) {
1526
1397
  const accountInstructions = instructions?.trim();
1527
1398
  return `# Notion usage