@zackbart/connecta 0.28.1 → 0.30.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 (332) hide show
  1. package/CHANGELOG.md +855 -5
  2. package/README.md +125 -116
  3. package/bin/connecta.mjs +121 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/argument-redaction.js +230 -0
  12. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  13. package/dist/auth/clerk-sdk/client.js +5 -0
  14. package/dist/auth/clerk-sdk/generated.js +1845 -0
  15. package/dist/auth/clerk-transport.js +19 -0
  16. package/dist/auth/clerk.d.ts +17 -7
  17. package/dist/auth/clerk.js +189 -107
  18. package/dist/auth/cloudflare-access.js +25 -26
  19. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  20. package/dist/auth/downstream-client-metadata.js +133 -0
  21. package/dist/auth/downstream-oauth.js +1584 -1580
  22. package/dist/auth/downstream-revocation.js +106 -0
  23. package/dist/auth/oauth-token-response.js +231 -0
  24. package/dist/auth/oauth-v2-migration.js +116 -0
  25. package/dist/auth/static-oauth.js +93 -71
  26. package/dist/auth-elicitation.js +288 -0
  27. package/dist/authorization-handoff.js +111 -0
  28. package/dist/branding.d.ts +8 -16
  29. package/dist/branding.js +26 -59
  30. package/dist/byte-read-response.js +39 -0
  31. package/dist/call-admission.js +12 -16
  32. package/dist/catalog-cache.js +739 -0
  33. package/dist/catalog-drift.d.ts +50 -15
  34. package/dist/catalog-drift.js +365 -128
  35. package/dist/catalog-fingerprint.js +2 -4
  36. package/dist/catalog-freshness.js +13 -0
  37. package/dist/catalog-limits.js +2 -2
  38. package/dist/catalog-service.js +409 -225
  39. package/dist/catalog.js +43 -84
  40. package/dist/concurrency.js +3 -4
  41. package/dist/config-defaults.d.ts +39 -0
  42. package/dist/config-defaults.js +46 -0
  43. package/dist/config-schema.d.ts +181 -0
  44. package/dist/config-schema.js +435 -0
  45. package/dist/config-value-sources.js +102 -0
  46. package/dist/config.d.ts +633 -0
  47. package/dist/config.js +508 -0
  48. package/dist/connector-access.d.ts +1 -0
  49. package/dist/connector-access.js +13 -15
  50. package/dist/connector-caller.d.ts +8 -0
  51. package/dist/connector-caller.js +15 -6
  52. package/dist/connector-scope.js +1 -1
  53. package/dist/connectors/api-connector.d.ts +59 -7
  54. package/dist/connectors/api-connector.js +106 -19
  55. package/dist/connectors/api.js +4 -1
  56. package/dist/connectors/guarded-fetch.d.ts +144 -0
  57. package/dist/connectors/guarded-fetch.js +87 -38
  58. package/dist/connectors/negotiation-cache.js +46 -0
  59. package/dist/connectors/option-shapes.js +27 -0
  60. package/dist/connectors/remote-mcp.d.ts +30 -4
  61. package/dist/connectors/remote-mcp.js +1774 -320
  62. package/dist/connectors/resource-uri.js +335 -0
  63. package/dist/credential-contract.d.ts +10 -0
  64. package/dist/credential-rules.js +1 -3
  65. package/dist/credential-url.js +108 -0
  66. package/dist/credentials.d.ts +6 -1
  67. package/dist/credentials.js +27 -18
  68. package/dist/d1.d.ts +130 -0
  69. package/dist/d1.js +290 -0
  70. package/dist/describe-config.d.ts +165 -0
  71. package/dist/describe-config.js +315 -0
  72. package/dist/described.js +106 -0
  73. package/dist/downstream-input-context.js +128 -0
  74. package/dist/downstream-input.js +325 -0
  75. package/dist/errors.d.ts +132 -31
  76. package/dist/errors.js +175 -87
  77. package/dist/execute.js +798 -318
  78. package/dist/executor-admission.d.ts +1 -0
  79. package/dist/executor-admission.js +11 -15
  80. package/dist/executor-contract.d.ts +20 -0
  81. package/dist/executor-contract.js +48 -0
  82. package/dist/executor-result.js +30 -13
  83. package/dist/executors/quickjs-child.js +15 -9
  84. package/dist/executors/quickjs-protocol.js +5 -6
  85. package/dist/executors/quickjs-runtime.js +152 -59
  86. package/dist/executors/quickjs.js +49 -39
  87. package/dist/favicon.js +1 -2
  88. package/dist/fix-prompt.js +1 -3
  89. package/dist/guest-runtime.js +238 -0
  90. package/dist/guest-types.d.ts +218 -0
  91. package/dist/guest-types.js +1 -0
  92. package/dist/html-security.js +24 -0
  93. package/dist/identity.js +1 -3
  94. package/dist/inbound-credential.js +52 -0
  95. package/dist/index.d.ts +24 -256
  96. package/dist/index.js +65 -478
  97. package/dist/invocation-auth.js +69 -0
  98. package/dist/invocation.js +262 -79
  99. package/dist/json-schema.d.ts +1 -1
  100. package/dist/mcp-client-context.js +35 -0
  101. package/dist/mcp-result.js +11 -7
  102. package/dist/meta-output.js +61 -0
  103. package/dist/meta-tool-names.d.ts +2 -0
  104. package/dist/meta-tool-names.js +3 -0
  105. package/dist/meta-tools.js +419 -272
  106. package/dist/module-contracts.d.ts +8 -23
  107. package/dist/node.d.ts +0 -2
  108. package/dist/node.js +2 -2
  109. package/dist/oauth-callback-outcome.js +1 -3
  110. package/dist/oauth-handoff.js +194 -0
  111. package/dist/oauth-sealing.js +20 -0
  112. package/dist/operator-record.d.ts +137 -0
  113. package/dist/operator-record.js +420 -0
  114. package/dist/operator-ui/assets.js +31 -0
  115. package/dist/operator-ui/contract.d.ts +72 -0
  116. package/dist/operator-ui/contract.js +1 -0
  117. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  118. package/dist/operator-ui/generated.js +5 -4
  119. package/dist/operator-ui/model.d.ts +12 -87
  120. package/dist/operator-ui/model.js +2 -9
  121. package/dist/operator-ui/setup-commands.js +1 -3
  122. package/dist/operator-ui/view.d.ts +323 -0
  123. package/dist/operator-ui/view.js +62 -189
  124. package/dist/page-styles.js +2 -3
  125. package/dist/program-result.js +5 -0
  126. package/dist/program-source.js +14 -2
  127. package/dist/{exempt-writes.js → program-writes.js} +60 -19
  128. package/dist/provider.d.ts +142 -0
  129. package/dist/provider.js +187 -0
  130. package/dist/providers/_shared/google/result-size.js +57 -0
  131. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  132. package/dist/providers/_shared/google/service-account.js +500 -0
  133. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  134. package/dist/providers/_shared/google/workspace.js +518 -0
  135. package/dist/providers/_shared/request-token.js +83 -0
  136. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  137. package/dist/providers/basecamp/index.js +997 -0
  138. package/dist/providers/basecamp/skill.generated.js +9 -0
  139. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  140. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  141. package/dist/providers/breeze/skill.generated.js +9 -0
  142. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  143. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  144. package/dist/providers/ccb/skill.generated.js +9 -0
  145. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  146. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  147. package/dist/providers/cloudflare/skill.generated.js +10 -0
  148. package/dist/providers/docs/index.d.ts +54 -0
  149. package/dist/providers/docs/index.js +1237 -0
  150. package/dist/providers/docs/skill.generated.js +9 -0
  151. package/dist/providers/drive/index.d.ts +53 -0
  152. package/dist/providers/drive/index.js +1523 -0
  153. package/dist/providers/drive/skill.generated.js +9 -0
  154. package/dist/providers/forms/index.d.ts +54 -0
  155. package/dist/providers/forms/index.js +1217 -0
  156. package/dist/providers/forms/skill.generated.js +11 -0
  157. package/dist/providers/github/auth.d.ts +32 -0
  158. package/dist/providers/github/auth.js +361 -0
  159. package/dist/providers/github/hosted.js +218 -0
  160. package/dist/providers/github/index.d.ts +13 -0
  161. package/dist/providers/github/index.js +140 -0
  162. package/dist/providers/github/key.js +100 -0
  163. package/dist/providers/github/rest.js +321 -0
  164. package/dist/providers/github/scope.d.ts +29 -0
  165. package/dist/providers/github/scope.js +124 -0
  166. package/dist/providers/github/skill.generated.js +8 -0
  167. package/dist/providers/gmail/index.d.ts +51 -0
  168. package/dist/providers/gmail/index.js +1436 -0
  169. package/dist/providers/gmail/skill.generated.js +9 -0
  170. package/dist/providers/infisical/index.d.ts +7 -0
  171. package/dist/providers/infisical/index.js +713 -0
  172. package/dist/providers/infisical/skill.generated.js +8 -0
  173. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  174. package/dist/providers/linear/index.js +352 -0
  175. package/dist/providers/linear/skill.generated.js +8 -0
  176. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  177. package/dist/providers/mixpanel/index.js +416 -0
  178. package/dist/providers/mixpanel/skill.generated.js +9 -0
  179. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  180. package/dist/providers/{notion.js → notion/index.js} +226 -239
  181. package/dist/providers/notion/skill.generated.js +10 -0
  182. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  183. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  184. package/dist/providers/overflow/skill.generated.js +8 -0
  185. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  186. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  187. package/dist/providers/planning-center/skill.generated.js +10 -0
  188. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  189. package/dist/providers/revenuecat/index.js +557 -0
  190. package/dist/providers/revenuecat/skill.generated.js +11 -0
  191. package/dist/providers/sheets/index.d.ts +54 -0
  192. package/dist/providers/sheets/index.js +1237 -0
  193. package/dist/providers/sheets/skill.generated.js +10 -0
  194. package/dist/providers/slides/index.d.ts +53 -0
  195. package/dist/providers/slides/index.js +2645 -0
  196. package/dist/providers/slides/skill.generated.js +9 -0
  197. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  198. package/dist/providers/stripe/index.js +266 -0
  199. package/dist/providers/stripe/skill.generated.js +11 -0
  200. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  201. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  202. package/dist/providers/tithely/skill.generated.js +8 -0
  203. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  204. package/dist/providers/vercel/index.js +1038 -0
  205. package/dist/providers/vercel/mcp-ownership.js +212 -0
  206. package/dist/providers/vercel/skill.generated.js +11 -0
  207. package/dist/registry.d.ts +110 -171
  208. package/dist/registry.js +588 -927
  209. package/dist/request-state.js +21 -0
  210. package/dist/resource-read.js +103 -0
  211. package/dist/result-shapes.js +17 -49
  212. package/dist/retired-toolkits.js +1 -1
  213. package/dist/routes/access-tokens.js +3 -6
  214. package/dist/routes/activity-context.js +21 -0
  215. package/dist/routes/activity-disclosure.js +40 -0
  216. package/dist/routes/activity.js +59 -21
  217. package/dist/routes/actor-labels.js +3 -50
  218. package/dist/routes/connect.js +221 -0
  219. package/dist/routes/credentials.js +15 -27
  220. package/dist/routes/mcp.js +365 -130
  221. package/dist/routes/oauth-client-metadata.js +15 -0
  222. package/dist/routes/oauth-management.js +45 -107
  223. package/dist/routes/oauth.js +110 -57
  224. package/dist/routes/operator.js +3 -3
  225. package/dist/routes/shared.d.ts +25 -64
  226. package/dist/routes/shared.js +160 -80
  227. package/dist/routes/skills.js +30 -0
  228. package/dist/routes/ui-config.js +225 -0
  229. package/dist/routes/ui-data.js +40 -35
  230. package/dist/routes/ui.js +66 -56
  231. package/dist/runtime/admission.js +4 -2
  232. package/dist/runtime/call-admission.js +3 -1
  233. package/dist/runtime/connector-scope.js +1 -4
  234. package/dist/runtime/run.js +10 -39
  235. package/dist/runtime/services.js +12 -117
  236. package/dist/runtime/shared-read.js +1 -3
  237. package/dist/runtime/storage.js +8 -1
  238. package/dist/sent-secrets.d.ts +93 -0
  239. package/dist/sent-secrets.js +1013 -0
  240. package/dist/server.js +28 -71
  241. package/dist/skills.js +416 -174
  242. package/dist/sqlite.d.ts +48 -0
  243. package/dist/sqlite.js +181 -0
  244. package/dist/storage/keys.js +443 -0
  245. package/dist/storage/memory.js +16 -4
  246. package/dist/storage/sql.d.ts +66 -0
  247. package/dist/storage/sql.js +543 -0
  248. package/dist/timeout.js +4 -2
  249. package/dist/tool-name.js +17 -0
  250. package/dist/tool-safety.d.ts +9 -35
  251. package/dist/tool-safety.js +20 -40
  252. package/dist/types.d.ts +336 -56
  253. package/dist/types.js +0 -1
  254. package/dist/ui.d.ts +7 -19
  255. package/dist/ui.js +76 -90
  256. package/dist/url-safety.js +3 -6
  257. package/dist/usage-guide.js +146 -0
  258. package/dist/validate.d.ts +7 -0
  259. package/dist/validate.js +309 -47
  260. package/dist/version.d.ts +1 -7
  261. package/dist/version.js +3 -7
  262. package/dist/worker.d.ts +11 -0
  263. package/dist/worker.js +435 -0
  264. package/documentation/README.md +63 -0
  265. package/documentation/architecture.md +696 -231
  266. package/documentation/auth.md +1069 -546
  267. package/documentation/code-mode.md +662 -356
  268. package/documentation/deploying.md +146 -0
  269. package/documentation/integrating.md +159 -0
  270. package/documentation/meta-tools.md +570 -216
  271. package/documentation/operating.md +135 -0
  272. package/documentation/operator-tests.md +97 -0
  273. package/documentation/operator-ui.md +132 -0
  274. package/documentation/provider-migration-0.29.md +65 -0
  275. package/examples/worker/AGENTS.md +34 -12
  276. package/examples/worker/README.md +293 -255
  277. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  278. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  279. package/examples/worker/src/connecta.config.ts +151 -0
  280. package/examples/worker/src/index.ts +14 -197
  281. package/examples/worker/wrangler.jsonc +15 -35
  282. package/package.json +126 -54
  283. package/templates/node/.dockerignore +1 -2
  284. package/templates/node/.env.example +20 -21
  285. package/templates/node/AGENTS.md +21 -17
  286. package/templates/node/Dockerfile +3 -5
  287. package/templates/node/README.md +140 -58
  288. package/templates/node/docker-compose.yml +11 -12
  289. package/templates/node/package.json +4 -2
  290. package/templates/node/src/connecta.config.ts +119 -0
  291. package/templates/node/src/index.ts +9 -154
  292. package/templates/node/src/provision-token.ts +17 -0
  293. package/AGENTS.md +0 -170
  294. package/dist/artifacts/connector.d.ts +0 -58
  295. package/dist/artifacts/connector.js +0 -730
  296. package/dist/artifacts/document.js +0 -86
  297. package/dist/artifacts/guide.js +0 -140
  298. package/dist/artifacts/html-scan.js +0 -329
  299. package/dist/artifacts/json.js +0 -78
  300. package/dist/artifacts/kv-store.d.ts +0 -20
  301. package/dist/artifacts/kv-store.js +0 -140
  302. package/dist/artifacts/markdown.js +0 -478
  303. package/dist/artifacts/module.d.ts +0 -37
  304. package/dist/artifacts/module.js +0 -66
  305. package/dist/artifacts/operations.d.ts +0 -197
  306. package/dist/artifacts/operations.js +0 -1002
  307. package/dist/artifacts/refresh.d.ts +0 -55
  308. package/dist/artifacts/refresh.js +0 -236
  309. package/dist/artifacts/routes.js +0 -206
  310. package/dist/artifacts/types.d.ts +0 -214
  311. package/dist/artifacts/types.js +0 -22
  312. package/dist/artifacts/validate.d.ts +0 -62
  313. package/dist/artifacts/validate.js +0 -651
  314. package/dist/artifacts.d.ts +0 -6
  315. package/dist/artifacts.js +0 -9
  316. package/dist/auth/bearer.d.ts +0 -11
  317. package/dist/auth/bearer.js +0 -50
  318. package/dist/providers/basecamp.js +0 -445
  319. package/dist/providers/linear.js +0 -217
  320. package/dist/providers/mixpanel.js +0 -247
  321. package/dist/providers/revenuecat.js +0 -317
  322. package/dist/providers/stripe.js +0 -253
  323. package/dist/providers/vercel.js +0 -1253
  324. package/dist/storage/file.d.ts +0 -15
  325. package/dist/storage/file.js +0 -455
  326. package/ethos.md +0 -93
  327. package/examples/worker/src/cloudflare-kv.ts +0 -43
  328. package/examples/worker/src/d1-activity-row.ts +0 -155
  329. package/examples/worker/src/d1-activity.ts +0 -147
  330. package/examples/worker/src/d1-storage.ts +0 -138
  331. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  332. package/templates/node/src/file-activity.ts +0 -182
@@ -0,0 +1,1217 @@
1
+ import { skill } from "./skill.generated.js";
2
+ /**
3
+ * Google Forms, as the signed-in Workspace user: read a form's questions,
4
+ * create a form, read its responses, and edit it through Google's own
5
+ * batchUpdate. Hand-written against the Forms API v1 reference
6
+ * (https://developers.google.com/workspace/forms/api/reference/rest).
7
+ *
8
+ * Whose forms. Access is a service account with domain-wide delegation
9
+ * (`src/providers/_shared/google/workspace.ts`): deployment config maps the admitted
10
+ * identity to a Workspace address, and each call mints, or reuses, a token as
11
+ * that user. The Forms API has no per-user path to confine beneath — a form id
12
+ * is a Drive file id — so the token's subject is what decides what a request
13
+ * can reach: exactly the forms that person can open in Forms.
14
+ *
15
+ * What it does not do. Listing forms is Drive's job (a Drive files query for
16
+ * `mimeType = 'application/vnd.google-apps.form'`); the Forms API has no list
17
+ * method and this connection requests no Drive scope. Deleting,
18
+ * sharing, and publishing are Drive or `setPublishSettings`, outside both of
19
+ * this connection's scopes or its surface, and watches (push notifications)
20
+ * are out of scope entirely. Responses are read-only: `forms.responses.readonly`
21
+ * is the only response scope requested.
22
+ *
23
+ * Drift. Google publishes a credential-free Discovery document per API.
24
+ * `src/providers/forms/drift.json` records the five methods the tools
25
+ * call, and `npm run providers:check -- --provider forms` reports a touched
26
+ * contract that moved or a method that stopped accepting the scopes below.
27
+ */
28
+ import { apiConnector as api, defined } from "../../connectors/api-connector.js";
29
+ import { ConnectorCallError } from "../../errors.js";
30
+ import { clampText, jsonBytes, RESULT_BUDGET_BYTES } from "../_shared/google/result-size.js";
31
+ import { googleOutcomeOf, GOOGLE_WORKSPACE_OPTIONS, googleWorkspaceClient, workspaceConnection, } from "../_shared/google/workspace.js";
32
+ import { asProviderFactory } from "../../provider.js";
33
+ /**
34
+ * The Forms API root. Every path the tools send sits beneath `/forms`; there
35
+ * is no user segment, because a form is a Drive file the token's subject can
36
+ * or cannot open.
37
+ */
38
+ export const FORMS_API_BASE_URL = "https://forms.googleapis.com/v1";
39
+ /**
40
+ * Exactly the scopes this connection requests, and exactly what the Admin
41
+ * console's domain-wide delegation entry must list (comma-separated there).
42
+ * `forms.body` reads and edits forms; responses are read-only.
43
+ */
44
+ export const FORMS_SCOPES = [
45
+ "https://www.googleapis.com/auth/forms.body",
46
+ "https://www.googleapis.com/auth/forms.responses.readonly",
47
+ ];
48
+ /** Google's own response page maximum is 5,000; a page an agent reads is not. */
49
+ const MAX_PAGE_SIZE = 100;
50
+ const DEFAULT_PAGE_SIZE = 25;
51
+ /** A listed answer is cut here; get_response returns it whole. */
52
+ const LIST_ANSWER_CHARS = 2_000;
53
+ /** A form or item title or description is cut here in get_form's projection. */
54
+ const DESCRIPTION_CHARS = 2_000;
55
+ /** ...and bounded here in bytes, so escaped control characters cannot grow it. */
56
+ const DESCRIPTION_BYTES = 8 * 1024;
57
+ /** Options shown per choice question; a long dropdown says how many it hid. */
58
+ const MAX_OPTIONS = 100;
59
+ /** An option or grid row is cut here; raw: true reads it whole. */
60
+ const MAX_LABEL_CHARS = 300;
61
+ const MAX_LABEL_BYTES = 1024;
62
+ /** One answer value in get_response, before it would crowd out the rest. */
63
+ const MAX_ANSWER_CHARS = 50_000;
64
+ /** Question ids one get_response call may name. */
65
+ const MAX_QUESTION_IDS = 100;
66
+ /** Requests in one batch_update_form call. */
67
+ const MAX_REQUESTS = 100;
68
+ /** A large form with embedded media metadata, or a page of long answers. */
69
+ const FORMS_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
70
+ /**
71
+ * Room a filling page keeps for its own fields and its cursor. Every result
72
+ * stays inside the shared RESULT_BUDGET_BYTES (`./google/result-size.ts`), so
73
+ * it crosses execute_code's host bridge and a direct call alike; a page ends
74
+ * early, with a cursor, rather than grow past it, and the complete result,
75
+ * cursor included, is measured before it is returned.
76
+ */
77
+ const PAGE_RESERVE_BYTES = 4 * 1024;
78
+ function asRecord(value) {
79
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
80
+ }
81
+ function asArray(value) {
82
+ return Array.isArray(value) ? value : [];
83
+ }
84
+ function compact(value) {
85
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
86
+ }
87
+ function text(value) {
88
+ return typeof value === "string" && value !== "" ? value : undefined;
89
+ }
90
+ function integer(value) {
91
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
92
+ }
93
+ /** Cut text at `max` characters and say so in the text itself. */
94
+ function capped(value, max, remedy) {
95
+ if (value === undefined || value.length <= max)
96
+ return value;
97
+ // Never split a surrogate pair: a lone high surrogate before the marker
98
+ // would make the text ill-formed.
99
+ let cut = value.slice(0, max);
100
+ const last = cut.charCodeAt(cut.length - 1);
101
+ if (last >= 0xd800 && last <= 0xdbff)
102
+ cut = cut.slice(0, -1);
103
+ return `${cut}\n[… ${value.length - cut.length} more characters truncated; ${remedy}]`;
104
+ }
105
+ /**
106
+ * Text cut at `chars` characters, then held to `bytes` of JSON — quotes and
107
+ * escapes counted — so a run of control characters cannot outgrow its place.
108
+ */
109
+ function bounded(value, chars, bytes) {
110
+ const cut = capped(value, chars, "pass raw: true to read it");
111
+ if (cut === undefined || jsonBytes(cut) <= bytes)
112
+ return cut;
113
+ return clampText(value, bytes, (dropped) => `\n[… ${dropped} more characters truncated; pass raw: true to read it]`);
114
+ }
115
+ function editUrl(formId) {
116
+ return `https://docs.google.com/forms/d/${encodeURIComponent(formId)}/edit`;
117
+ }
118
+ const encoder = new TextEncoder();
119
+ function base64Url(bytes) {
120
+ let binary = "";
121
+ for (const byte of bytes)
122
+ binary += String.fromCharCode(byte);
123
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
124
+ }
125
+ /** What a cursor is bound to: its tool and every argument that decides its pages. */
126
+ async function digestOf(bound) {
127
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify(bound))));
128
+ return base64Url(hash.subarray(0, 16));
129
+ }
130
+ function encodeCursor(cursor) {
131
+ return base64Url(encoder.encode(JSON.stringify(cursor)));
132
+ }
133
+ /**
134
+ * A cursor this tool issued for these same arguments, or a refusal: one
135
+ * carried to another form, filter, or tool would page through something else.
136
+ */
137
+ function decodeCursor(value, digest, keys, valid, again) {
138
+ let cursor;
139
+ try {
140
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
141
+ const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
142
+ cursor = asRecord(JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)))));
143
+ }
144
+ catch {
145
+ cursor = {};
146
+ }
147
+ const shaped = Object.keys(cursor).length === keys.length && keys.every((key) => key in cursor);
148
+ if (!shaped || cursor["d"] !== digest || !valid(cursor)) {
149
+ throw new ConnectorCallError("invalid_args", `cursor is not one this tool returned for these arguments. Pass page.nextCursor back unchanged, ${again}.`);
150
+ }
151
+ return cursor;
152
+ }
153
+ function isIndex(value) {
154
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
155
+ }
156
+ const CHOICE_TYPES = {
157
+ RADIO: "radio",
158
+ CHECKBOX: "checkbox",
159
+ DROP_DOWN: "drop_down",
160
+ };
161
+ function choiceOptions(choice) {
162
+ const all = asArray(choice["options"]).map(asRecord);
163
+ const hasOther = all.some((option) => option["isOther"] === true);
164
+ const values = all
165
+ .filter((option) => option["isOther"] !== true)
166
+ .map((option) => bounded(String(option["value"] ?? ""), MAX_LABEL_CHARS, MAX_LABEL_BYTES) ?? "");
167
+ return compact({
168
+ options: values.slice(0, MAX_OPTIONS),
169
+ moreOptions: values.length > MAX_OPTIONS ? values.length - MAX_OPTIONS : undefined,
170
+ hasOther: hasOther || undefined,
171
+ });
172
+ }
173
+ /** One question's id, type, and the shape of what it accepts. */
174
+ function projectQuestion(value, rowTitle) {
175
+ const question = asRecord(value);
176
+ const grading = asRecord(question["grading"]);
177
+ const base = {
178
+ questionId: text(question["questionId"]),
179
+ rowTitle,
180
+ required: question["required"] === true,
181
+ points: integer(grading["pointValue"]),
182
+ };
183
+ if (question["choiceQuestion"]) {
184
+ const choice = asRecord(question["choiceQuestion"]);
185
+ return compact({ ...base, type: CHOICE_TYPES[String(choice["type"])] ?? "unknown", ...choiceOptions(choice) });
186
+ }
187
+ if (question["textQuestion"]) {
188
+ return compact({
189
+ ...base,
190
+ type: asRecord(question["textQuestion"])["paragraph"] === true ? "paragraph" : "short_text",
191
+ });
192
+ }
193
+ if (question["scaleQuestion"]) {
194
+ const scale = asRecord(question["scaleQuestion"]);
195
+ return compact({
196
+ ...base,
197
+ type: "scale",
198
+ scale: compact({
199
+ low: integer(scale["low"]),
200
+ high: integer(scale["high"]),
201
+ lowLabel: text(scale["lowLabel"]),
202
+ highLabel: text(scale["highLabel"]),
203
+ }),
204
+ });
205
+ }
206
+ if (question["dateQuestion"]) {
207
+ const date = asRecord(question["dateQuestion"]);
208
+ return compact({
209
+ ...base,
210
+ type: "date",
211
+ includesYear: date["includeYear"] === true,
212
+ includesTime: date["includeTime"] === true,
213
+ });
214
+ }
215
+ if (question["timeQuestion"]) {
216
+ return compact({ ...base, type: "time", duration: asRecord(question["timeQuestion"])["duration"] === true });
217
+ }
218
+ if (question["ratingQuestion"]) {
219
+ const rating = asRecord(question["ratingQuestion"]);
220
+ return compact({ ...base, type: "rating", scale: compact({ low: 1, high: integer(rating["ratingScaleLevel"]) }) });
221
+ }
222
+ if (question["fileUploadQuestion"])
223
+ return compact({ ...base, type: "file_upload" });
224
+ if (question["rowQuestion"])
225
+ return compact({ ...base, type: "grid_row" });
226
+ return compact({ ...base, type: "unknown" });
227
+ }
228
+ /**
229
+ * One item, with every question it asks under `questions`: one for a question
230
+ * item, one per row for a grid, none for a section break or media. Answers are
231
+ * keyed by question id, so this is the map a response is read through.
232
+ */
233
+ function projectItem(value) {
234
+ const item = asRecord(value);
235
+ const base = {
236
+ itemId: text(item["itemId"]),
237
+ title: bounded(text(item["title"]), DESCRIPTION_CHARS, DESCRIPTION_BYTES),
238
+ description: bounded(text(item["description"]), DESCRIPTION_CHARS, DESCRIPTION_BYTES),
239
+ };
240
+ if (item["questionItem"]) {
241
+ return compact({
242
+ ...base,
243
+ kind: "question",
244
+ questions: [projectQuestion(asRecord(item["questionItem"])["question"])],
245
+ });
246
+ }
247
+ if (item["questionGroupItem"]) {
248
+ const group = asRecord(item["questionGroupItem"]);
249
+ const grid = asRecord(group["grid"]);
250
+ const columns = asRecord(grid["columns"]);
251
+ return compact({
252
+ ...base,
253
+ kind: "question_group",
254
+ // A grid's rows share its columns: the choices are stated once, here.
255
+ gridType: group["grid"] ? (CHOICE_TYPES[String(columns["type"])] ?? "unknown") : undefined,
256
+ ...(group["grid"] ? choiceOptions(columns) : {}),
257
+ questions: asArray(group["questions"]).map((question) => projectQuestion(question, bounded(text(asRecord(asRecord(question)["rowQuestion"])["title"]), MAX_LABEL_CHARS, MAX_LABEL_BYTES))),
258
+ });
259
+ }
260
+ const kind = item["pageBreakItem"]
261
+ ? "page_break"
262
+ : item["textItem"]
263
+ ? "text"
264
+ : item["imageItem"]
265
+ ? "image"
266
+ : item["videoItem"]
267
+ ? "video"
268
+ : "unknown";
269
+ return compact({ ...base, kind });
270
+ }
271
+ function projectForm(value) {
272
+ const form = asRecord(value);
273
+ const info = asRecord(form["info"]);
274
+ const settings = asRecord(form["settings"]);
275
+ const publish = asRecord(asRecord(form["publishSettings"])["publishState"]);
276
+ const formId = text(form["formId"]);
277
+ const items = asArray(form["items"]).map(projectItem);
278
+ return compact({
279
+ formId,
280
+ title: bounded(text(info["title"]), DESCRIPTION_CHARS, DESCRIPTION_BYTES),
281
+ documentTitle: bounded(text(info["documentTitle"]), DESCRIPTION_CHARS, DESCRIPTION_BYTES),
282
+ description: bounded(text(info["description"]), DESCRIPTION_CHARS, DESCRIPTION_BYTES),
283
+ revisionId: text(form["revisionId"]),
284
+ responderUri: text(form["responderUri"]),
285
+ editUrl: formId ? editUrl(formId) : undefined,
286
+ linkedSheetId: text(form["linkedSheetId"]),
287
+ isQuiz: asRecord(settings["quizSettings"])["isQuiz"] === true,
288
+ emailCollection: text(settings["emailCollectionType"]),
289
+ // Absent on a legacy form, which has no publish settings at all.
290
+ published: form["publishSettings"] ? publish["isPublished"] === true : undefined,
291
+ acceptingResponses: form["publishSettings"] ? publish["isAcceptingResponses"] === true : undefined,
292
+ itemCount: items.length,
293
+ questionCount: items.reduce((count, item) => count + asArray(item["questions"]).length, 0),
294
+ items,
295
+ });
296
+ }
297
+ /**
298
+ * An item cut to `budget` bytes. Its own text and every option and label are
299
+ * already bounded (an item without questions stays under ~120 KiB), so only a
300
+ * grid's rows can outgrow a page; the rows that fit are kept and the rest
301
+ * counted in `moreQuestions`, and the page's cursor continues inside this
302
+ * item from the first row it left out. The caller still measures what it
303
+ * returns.
304
+ */
305
+ function fitItem(item, budget) {
306
+ if (jsonBytes(item) <= budget)
307
+ return item;
308
+ const questions = asArray(item["questions"]);
309
+ const kept = [];
310
+ let used = jsonBytes({ ...item, questions: [], moreQuestions: questions.length });
311
+ for (const question of questions) {
312
+ const size = jsonBytes(question) + 1;
313
+ if (used + size > budget)
314
+ break;
315
+ kept.push(question);
316
+ used += size;
317
+ }
318
+ return { ...item, questions: kept, moreQuestions: questions.length - kept.length };
319
+ }
320
+ /**
321
+ * An item's questions from `from` on, for a page that continues inside it.
322
+ * Its description and options went out with its first part; the continuation
323
+ * carries what places the rows — its id, kind, and title — and where they
324
+ * start, in `questionsFrom`.
325
+ */
326
+ function continuedItem(item, from) {
327
+ if (from === 0)
328
+ return item;
329
+ return compact({
330
+ itemId: item["itemId"],
331
+ kind: item["kind"],
332
+ title: item["title"],
333
+ questionsFrom: from,
334
+ questions: asArray(item["questions"]).slice(from),
335
+ });
336
+ }
337
+ /**
338
+ * Every question id in the form, labeled the way a person reads it — the
339
+ * item's title, and the row's for a grid — in the form's own order.
340
+ */
341
+ function questionLabels(form) {
342
+ const labels = new Map();
343
+ for (const value of asArray(asRecord(form)["items"])) {
344
+ const item = projectItem(value);
345
+ for (const question of asArray(item["questions"]).map(asRecord)) {
346
+ const id = text(question["questionId"]);
347
+ if (!id)
348
+ continue;
349
+ const title = question["rowTitle"] ? `${item["title"] ?? ""} [${question["rowTitle"]}]`.trim() : item["title"];
350
+ labels.set(id, { title, order: labels.size });
351
+ }
352
+ }
353
+ return labels;
354
+ }
355
+ /**
356
+ * A grader's feedback on one answer: its text, and the links or videos it
357
+ * points to, each as the address a person opens and the label they were shown.
358
+ */
359
+ function projectFeedback(value, maxChars, remedy) {
360
+ if (!value || typeof value !== "object")
361
+ return undefined;
362
+ const feedback = asRecord(value);
363
+ const links = asArray(feedback["material"]).map((entry) => {
364
+ const material = asRecord(entry);
365
+ const link = asRecord(material["link"]);
366
+ const video = asRecord(material["video"]);
367
+ return compact({
368
+ uri: text(link["uri"]) ?? text(video["youtubeUri"]),
369
+ displayText: text(link["displayText"]) ?? text(video["displayText"]),
370
+ video: material["video"] ? true : undefined,
371
+ });
372
+ });
373
+ const said = text(feedback["text"]);
374
+ return compact({
375
+ text: maxChars === undefined ? said : capped(said, maxChars, remedy),
376
+ links: links.length > 0 ? links : undefined,
377
+ });
378
+ }
379
+ function projectResponse(value, labels, maxChars, remedy) {
380
+ const response = asRecord(value);
381
+ const answers = Object.entries(asRecord(response["answers"])).map(([key, raw]) => {
382
+ const answer = asRecord(raw);
383
+ const questionId = text(answer["questionId"]) ?? key;
384
+ const grade = asRecord(answer["grade"]);
385
+ const values = asArray(asRecord(answer["textAnswers"])["answers"]).map((entry) => {
386
+ const value = String(asRecord(entry)["value"] ?? "");
387
+ return maxChars === undefined ? value : capped(value, maxChars, remedy);
388
+ });
389
+ const files = asArray(asRecord(answer["fileUploadAnswers"])["answers"]).map((entry) => {
390
+ const file = asRecord(entry);
391
+ return compact({
392
+ fileId: text(file["fileId"]),
393
+ fileName: text(file["fileName"]),
394
+ mimeType: text(file["mimeType"]),
395
+ });
396
+ });
397
+ return compact({
398
+ questionId,
399
+ // Undefined for a question deleted since this response was submitted.
400
+ title: labels.get(questionId)?.title,
401
+ values: files.length > 0 && values.length === 0 ? undefined : values,
402
+ files: files.length > 0 ? files : undefined,
403
+ score: typeof grade["score"] === "number" ? grade["score"] : undefined,
404
+ correct: typeof grade["correct"] === "boolean" ? grade["correct"] : undefined,
405
+ feedback: projectFeedback(grade["feedback"], maxChars, remedy),
406
+ });
407
+ });
408
+ // The form's order, then anything the form no longer asks.
409
+ const rank = (id) => labels.get(id)?.order ?? Number.MAX_SAFE_INTEGER;
410
+ answers.sort((a, b) => rank(a.questionId) - rank(b.questionId));
411
+ return compact({
412
+ responseId: text(response["responseId"]),
413
+ createTime: text(response["createTime"]),
414
+ lastSubmittedTime: text(response["lastSubmittedTime"]),
415
+ respondentEmail: text(response["respondentEmail"]),
416
+ totalScore: typeof response["totalScore"] === "number" ? response["totalScore"] : undefined,
417
+ answers,
418
+ });
419
+ }
420
+ /**
421
+ * A response projected inside `budget` bytes: answers cut at each of `caps`
422
+ * in turn until it fits, and if even the last does not, answers left out from
423
+ * the end and named in `omittedQuestionIds`, for get_response to read by id.
424
+ */
425
+ function fitResponse(value, labels, caps, remedy, budget) {
426
+ let projected = {};
427
+ for (const cap of caps) {
428
+ projected = projectResponse(value, labels, cap, remedy);
429
+ if (jsonBytes(projected) <= budget)
430
+ return projected;
431
+ }
432
+ const answers = asArray(projected["answers"]).map(asRecord);
433
+ const kept = [];
434
+ let used = jsonBytes({
435
+ ...projected,
436
+ answers: [],
437
+ omittedQuestionIds: answers.map((answer) => answer["questionId"]),
438
+ });
439
+ for (const answer of answers) {
440
+ const size = jsonBytes(answer) + 1;
441
+ if (used + size > budget)
442
+ break;
443
+ kept.push(answer);
444
+ used += size;
445
+ }
446
+ return {
447
+ ...projected,
448
+ answers: kept,
449
+ omittedQuestionIds: answers.slice(kept.length).map((answer) => answer["questionId"]),
450
+ };
451
+ }
452
+ // --- Writing ----------------------------------------------------------------------
453
+ /** What a batch reply says it made: the new item and its question ids. */
454
+ function projectReplies(value) {
455
+ return asArray(value).map((entry) => {
456
+ const created = asRecord(asRecord(entry)["createItem"]);
457
+ return asRecord(entry)["createItem"]
458
+ ? compact({
459
+ createdItemId: text(created["itemId"]),
460
+ createdQuestionIds: asArray(created["questionId"]).filter((id) => typeof id === "string"),
461
+ })
462
+ : {};
463
+ });
464
+ }
465
+ /**
466
+ * A batch's reply, inside one result. The write has already applied by the
467
+ * time this runs, so nothing here may read as failure or as nothing changed:
468
+ * a reply too large to return whole — a grid created with thousands of rows
469
+ * names every row's question id — drops the question ids first and keeps each
470
+ * created item's id, and if even that cannot fit, keeps only the counts.
471
+ * Either way `truncated` and `note` say the batch applied and where to read
472
+ * what was left out.
473
+ */
474
+ function projectBatch(formId, value) {
475
+ const result = asRecord(value);
476
+ const revisionId = text(asRecord(result["writeControl"])["requiredRevisionId"]);
477
+ const replies = projectReplies(result["replies"]);
478
+ const whole = compact({ formId, revisionId, replies });
479
+ if (jsonBytes(whole) <= RESULT_BUDGET_BYTES)
480
+ return whole;
481
+ const note = "The batch applied. Its reply was too large to return whole; read the created items' question ids with get_form, following page.nextCursor to the last page.";
482
+ const idsOnly = compact({
483
+ formId,
484
+ revisionId,
485
+ replies: replies.map((reply) => compact({ createdItemId: reply["createdItemId"] })),
486
+ truncated: true,
487
+ note,
488
+ });
489
+ if (jsonBytes(idsOnly) <= RESULT_BUDGET_BYTES)
490
+ return idsOnly;
491
+ return compact({
492
+ formId,
493
+ revisionId: revisionId && jsonBytes(revisionId) <= 1024 ? revisionId : undefined,
494
+ replies: [],
495
+ replyCount: replies.length,
496
+ createdItemCount: replies.filter((reply) => reply["createdItemId"] !== undefined).length,
497
+ truncated: true,
498
+ note: revisionId && jsonBytes(revisionId) <= 1024 ? note : `${note} Read its revisionId there too.`,
499
+ });
500
+ }
501
+ // --- Schemas ----------------------------------------------------------------------
502
+ function input(properties, required) {
503
+ return { type: "object", properties, required, additionalProperties: false };
504
+ }
505
+ const ID_PATTERN = "^[A-Za-z0-9_-]+$";
506
+ function idProperty(description) {
507
+ return { type: "string", minLength: 1, maxLength: 256, pattern: ID_PATTERN, description };
508
+ }
509
+ const FORM_ID_PROPERTY = idProperty("Form id: the part after /forms/d/ in its edit URL, a Drive file id, or create_form's formId. Not the /forms/d/e/… responder link.");
510
+ const REVISION_PROPERTY = {
511
+ type: "string",
512
+ minLength: 1,
513
+ maxLength: 256,
514
+ pattern: "^\\S+$",
515
+ description: "revisionId from get_form, create_form, or the previous edit. Google refuses the edit if the form changed since; re-read it and retry.",
516
+ };
517
+ function cursorProperty(same) {
518
+ return {
519
+ type: "string",
520
+ minLength: 1,
521
+ maxLength: 4096,
522
+ description: `Opaque page.nextCursor from the previous page. Pass it back unchanged with the same ${same}.`,
523
+ };
524
+ }
525
+ const LIMIT_PROPERTY = {
526
+ type: "integer",
527
+ minimum: 1,
528
+ maximum: MAX_PAGE_SIZE,
529
+ description: `Responses per page, 1 to ${MAX_PAGE_SIZE}; defaults to ${DEFAULT_PAGE_SIZE}. Connecta's cap, far below Google's 5,000; page with the cursor.`,
530
+ };
531
+ const RFC3339_UTC = "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?Z$";
532
+ const PAGE_SCHEMA = {
533
+ type: "object",
534
+ properties: {
535
+ hasMore: { type: "boolean" },
536
+ nextCursor: { type: ["string", "null"] },
537
+ },
538
+ required: ["hasMore", "nextCursor"],
539
+ };
540
+ const FILE_SCHEMA = {
541
+ type: "object",
542
+ properties: {
543
+ fileId: { type: "string" },
544
+ fileName: { type: "string" },
545
+ mimeType: { type: "string" },
546
+ },
547
+ };
548
+ const FEEDBACK_SCHEMA = {
549
+ type: "object",
550
+ properties: {
551
+ text: { type: "string" },
552
+ links: {
553
+ type: "array",
554
+ items: {
555
+ type: "object",
556
+ properties: {
557
+ uri: { type: "string" },
558
+ displayText: { type: "string" },
559
+ video: { type: "boolean" },
560
+ },
561
+ },
562
+ },
563
+ },
564
+ };
565
+ const RESPONSE_SCHEMA = {
566
+ type: "object",
567
+ properties: {
568
+ responseId: { type: "string" },
569
+ createTime: { type: "string" },
570
+ lastSubmittedTime: { type: "string" },
571
+ respondentEmail: { type: "string" },
572
+ totalScore: { type: "number" },
573
+ omittedQuestionIds: { type: "array", items: { type: "string" } },
574
+ answers: {
575
+ type: "array",
576
+ items: {
577
+ type: "object",
578
+ properties: {
579
+ questionId: { type: "string" },
580
+ title: { type: "string" },
581
+ values: { type: "array", items: { type: "string" } },
582
+ files: { type: "array", items: FILE_SCHEMA },
583
+ score: { type: "number" },
584
+ correct: { type: "boolean" },
585
+ feedback: FEEDBACK_SCHEMA,
586
+ },
587
+ },
588
+ },
589
+ },
590
+ };
591
+ /** A choice question's, or a grid's shared, options. */
592
+ const CHOICE_PROPERTIES = {
593
+ options: { type: "array", items: { type: "string" } },
594
+ moreOptions: { type: "integer" },
595
+ hasOther: { type: "boolean" },
596
+ };
597
+ const QUESTION_SCHEMA = {
598
+ type: "object",
599
+ properties: {
600
+ questionId: { type: "string" },
601
+ type: { type: "string" },
602
+ rowTitle: { type: "string" },
603
+ required: { type: "boolean" },
604
+ points: { type: "integer" },
605
+ ...CHOICE_PROPERTIES,
606
+ scale: {
607
+ type: "object",
608
+ properties: {
609
+ low: { type: "integer" },
610
+ high: { type: "integer" },
611
+ lowLabel: { type: "string" },
612
+ highLabel: { type: "string" },
613
+ },
614
+ },
615
+ includesYear: { type: "boolean" },
616
+ includesTime: { type: "boolean" },
617
+ duration: { type: "boolean" },
618
+ },
619
+ };
620
+ /**
621
+ * get_form's result. Only `formId` is required, because `raw: true` returns
622
+ * Google's own Form resource instead, where ProtoJSON omits every empty field —
623
+ * an empty form has no `items` at all. The projection always carries `items`.
624
+ */
625
+ const FORM_SCHEMA = {
626
+ type: "object",
627
+ description: "The projection; with raw: true, Google's Form resource (formId, info, settings, publishSettings, items, revisionId), empty fields omitted.",
628
+ properties: {
629
+ formId: { type: "string" },
630
+ title: { type: "string" },
631
+ documentTitle: { type: "string" },
632
+ description: { type: "string" },
633
+ revisionId: { type: "string" },
634
+ responderUri: { type: "string" },
635
+ editUrl: { type: "string" },
636
+ linkedSheetId: { type: "string" },
637
+ isQuiz: { type: "boolean" },
638
+ emailCollection: { type: "string" },
639
+ published: { type: "boolean" },
640
+ acceptingResponses: { type: "boolean" },
641
+ itemCount: { type: "integer" },
642
+ questionCount: { type: "integer" },
643
+ page: PAGE_SCHEMA,
644
+ items: {
645
+ type: "array",
646
+ items: {
647
+ type: "object",
648
+ properties: {
649
+ itemId: { type: "string" },
650
+ kind: { type: "string" },
651
+ title: { type: "string" },
652
+ description: { type: "string" },
653
+ gridType: { type: "string" },
654
+ ...CHOICE_PROPERTIES,
655
+ questions: { type: "array", items: QUESTION_SCHEMA },
656
+ questionsFrom: { type: "integer" },
657
+ moreQuestions: { type: "integer" },
658
+ },
659
+ },
660
+ },
661
+ },
662
+ required: ["formId"],
663
+ };
664
+ /**
665
+ * One Forms `Request`: exactly one of Google's six kinds, each shaped as
666
+ * Google documents it. The kind is closed here; its body is Google's.
667
+ */
668
+ function requestKind(description) {
669
+ return { type: "object", description };
670
+ }
671
+ const REQUEST_SCHEMA = {
672
+ type: "object",
673
+ minProperties: 1,
674
+ maxProperties: 1,
675
+ additionalProperties: false,
676
+ properties: {
677
+ createItem: requestKind("{ item, location: { index } }"),
678
+ updateItem: requestKind("{ item, location: { index }, updateMask }"),
679
+ deleteItem: requestKind("{ location: { index } }"),
680
+ moveItem: requestKind("{ originalLocation, newLocation }"),
681
+ updateFormInfo: requestKind("{ info: { title?, description? }, updateMask }"),
682
+ updateSettings: requestKind("{ settings: { quizSettings?, emailCollectionType? }, updateMask }"),
683
+ },
684
+ };
685
+ // --- Tools ------------------------------------------------------------------------
686
+ function tools(client) {
687
+ const readOnly = { readOnlyHint: true };
688
+ const formPath = (formId) => `/forms/${encodeURIComponent(formId)}`;
689
+ const readForm = (formId, ctx) => client.json({ method: "GET", path: formPath(formId) }, ctx);
690
+ const batch = async (formId, requests, revisionId, ctx) => projectBatch(formId, await client.json({
691
+ method: "POST",
692
+ path: `${formPath(formId)}:batchUpdate`,
693
+ body: compact({
694
+ requests,
695
+ writeControl: revisionId ? { requiredRevisionId: revisionId } : undefined,
696
+ }),
697
+ }, ctx,
698
+ // A named revision makes Google's refused precondition mean "the form
699
+ // moved on", which the shared client reports as `conflict`.
700
+ { revisionGuarded: revisionId !== undefined }));
701
+ return [
702
+ {
703
+ name: "get_form",
704
+ description: "Get one Google Form: title, description, quiz and publish state, revisionId, and its items with question ids, types, and options, paged when large. Never lists forms.",
705
+ annotations: readOnly,
706
+ inputSchema: input({
707
+ formId: FORM_ID_PROPERTY,
708
+ raw: {
709
+ type: "boolean",
710
+ description: "Return Google's untouched Form resource (grading, images, section navigation) instead of the projection; read it before an updateItem that must keep them. Not paged: a form too large for one result is refused.",
711
+ },
712
+ cursor: cursorProperty("formId"),
713
+ }, ["formId"]),
714
+ outputSchema: FORM_SCHEMA,
715
+ handler: async (args, ctx) => {
716
+ const formId = args["formId"];
717
+ if (args["raw"] === true && args["cursor"] !== undefined) {
718
+ throw new ConnectorCallError("invalid_args", "raw: true is not paged; omit cursor.");
719
+ }
720
+ const form = await readForm(formId, ctx);
721
+ if (args["raw"] === true) {
722
+ const size = jsonBytes(form);
723
+ if (size > RESULT_BUDGET_BYTES) {
724
+ throw new ConnectorCallError("invalid_args", `This form's raw resource is ${size} bytes, more than one result carries (${RESULT_BUDGET_BYTES}). Read the projection, which pages its items with page.nextCursor, or edit with the item indexes it lists.`);
725
+ }
726
+ return form;
727
+ }
728
+ const { items, ...header } = projectForm(form);
729
+ const all = asArray(items).map(asRecord);
730
+ const revision = text(asRecord(form)["revisionId"]) ?? "";
731
+ const digest = await digestOf(["get_form", formId]);
732
+ // A cursor names an item and, inside a grid too large for one page,
733
+ // the first of its questions not yet returned, so every question id
734
+ // is reachable by paging, however many rows one grid has.
735
+ let start = 0;
736
+ let from = 0;
737
+ if (typeof args["cursor"] === "string") {
738
+ const cursor = decodeCursor(args["cursor"], digest, ["d", "i", "q", "r"], (c) => isIndex(c["i"]) && isIndex(c["q"]) && typeof c["r"] === "string", "with the same formId");
739
+ // Items are addressed by index and rows by position, so a page of a
740
+ // changed form would skip or repeat them without saying so.
741
+ const at = all[cursor["i"]];
742
+ if (cursor["r"] !== revision ||
743
+ cursor["i"] > all.length ||
744
+ (cursor["q"] > 0 && (at === undefined || cursor["q"] >= asArray(at["questions"]).length))) {
745
+ throw new ConnectorCallError("conflict", "The form changed after the page this cursor continues. Read get_form again from the start, without cursor.");
746
+ }
747
+ start = cursor["i"];
748
+ from = cursor["q"];
749
+ }
750
+ const piece = (index) => (index === start ? continuedItem(all[index], from) : all[index]);
751
+ const kept = [];
752
+ let used = jsonBytes({ ...header, items: [] }) + PAGE_RESERVE_BYTES;
753
+ for (let index = start; index < all.length; index += 1) {
754
+ let item = piece(index);
755
+ let size = jsonBytes(item) + 1;
756
+ if (used + size > RESULT_BUDGET_BYTES) {
757
+ if (kept.length > 0)
758
+ break;
759
+ item = fitItem(item, RESULT_BUDGET_BYTES - used);
760
+ size = jsonBytes(item) + 1;
761
+ }
762
+ kept.push(item);
763
+ used += size;
764
+ // A grid cut short ends the page: the cursor continues inside it.
765
+ if (item["moreQuestions"])
766
+ break;
767
+ }
768
+ const build = () => {
769
+ const lastIndex = start + kept.length - 1;
770
+ const last = kept[kept.length - 1];
771
+ let next;
772
+ if (last?.["moreQuestions"]) {
773
+ const before = lastIndex === start ? from : 0;
774
+ next = { d: digest, i: lastIndex, q: before + asArray(last["questions"]).length, r: revision };
775
+ }
776
+ else if (lastIndex + 1 < all.length) {
777
+ next = { d: digest, i: lastIndex + 1, q: 0, r: revision };
778
+ }
779
+ const nextCursor = next ? encodeCursor(next) : null;
780
+ return { ...header, page: { hasMore: nextCursor !== null, nextCursor }, items: kept };
781
+ };
782
+ // The estimate above reserved room; the complete result, cursor and
783
+ // all, is what has to fit.
784
+ let result = build();
785
+ while (jsonBytes(result) > RESULT_BUDGET_BYTES && kept.length > 1) {
786
+ kept.pop();
787
+ result = build();
788
+ }
789
+ if (jsonBytes(result) > RESULT_BUDGET_BYTES && kept.length === 1) {
790
+ // Measured with the longest cursor this page could carry.
791
+ const room = RESULT_BUDGET_BYTES - jsonBytes({ ...result, items: [] }) - 64;
792
+ kept[0] = fitItem(piece(start), room);
793
+ result = build();
794
+ }
795
+ // A page must move forward: a continued grid that cannot fit even one
796
+ // row would hand back the cursor it was given.
797
+ const first = kept[0];
798
+ const stalled = first?.["moreQuestions"] !== undefined && asArray(first["questions"]).length === 0;
799
+ if (jsonBytes(result) > RESULT_BUDGET_BYTES || stalled) {
800
+ throw new ConnectorCallError("connector_call_failed", `Item ${start} of this form cannot be projected inside one result (${RESULT_BUDGET_BYTES} bytes), even alone; open the form in Forms.`, { retryable: false });
801
+ }
802
+ return result;
803
+ },
804
+ },
805
+ {
806
+ name: "create_form",
807
+ description: "Create an empty Google Form with a title in the user's Drive. Google sets only the title here: add questions and a description with batch_update_form.",
808
+ // Additive: a new, empty form changes nothing that existed. Not
809
+ // read-only: program calls require a trusted pool; other writes use
810
+ // call_destructive_tool. The host controls approval.
811
+ annotations: { readOnlyHint: false, destructiveHint: false },
812
+ inputSchema: input({
813
+ title: {
814
+ type: "string",
815
+ minLength: 1,
816
+ maxLength: 1_000,
817
+ description: "Title responders see.",
818
+ },
819
+ documentTitle: {
820
+ type: "string",
821
+ minLength: 1,
822
+ maxLength: 1_000,
823
+ description: "File name in Drive; defaults to the title. It cannot be changed through this connection later.",
824
+ },
825
+ unpublished: {
826
+ type: "boolean",
827
+ description: "Create it not accepting responses. This connection cannot publish it later; the owner does, in Forms. Omit to publish.",
828
+ },
829
+ }, ["title"]),
830
+ outputSchema: {
831
+ type: "object",
832
+ properties: {
833
+ formId: { type: "string" },
834
+ title: { type: "string" },
835
+ documentTitle: { type: "string" },
836
+ revisionId: { type: "string" },
837
+ responderUri: { type: "string" },
838
+ editUrl: { type: "string" },
839
+ },
840
+ required: ["formId"],
841
+ },
842
+ handler: async (args, ctx) => {
843
+ let created;
844
+ try {
845
+ created = await client.json({
846
+ method: "POST",
847
+ path: "/forms",
848
+ query: { unpublished: args["unpublished"] === true ? true : undefined },
849
+ body: { info: compact({ title: args["title"], documentTitle: args["documentTitle"] }) },
850
+ }, ctx);
851
+ }
852
+ catch (error) {
853
+ // Sent and not refused — no answer, a 5xx or redirect after Google received
854
+ // it, or a reply that broke: the form may exist, and there is no
855
+ // form id to re-read. Another create would make a second one, so
856
+ // say where to look first. The shared verdict (not retryable)
857
+ // stands. No Forms write is sent as idempotent: a create, a batch,
858
+ // and an info update are each not safe to send twice blind.
859
+ // Any phase that is neither "never sent" nor "explicitly refused"
860
+ // is uncertain, so a phase the shared layer adds later is too.
861
+ const outcome = googleOutcomeOf(error);
862
+ if (error instanceof ConnectorCallError &&
863
+ outcome?.dispatched === true &&
864
+ outcome.phase !== "refused" &&
865
+ outcome.phase !== "before-send") {
866
+ throw new ConnectorCallError(error.code, `${error.message} create_form makes a new form on every call: look for "${args["documentTitle"] ?? args["title"]}" in the user's Drive, or ask them, before creating it again.`, { retryable: false, cause: error });
867
+ }
868
+ throw error;
869
+ }
870
+ const form = asRecord(created);
871
+ const formId = text(form["formId"]);
872
+ const info = asRecord(form["info"]);
873
+ return compact({
874
+ formId,
875
+ title: text(info["title"]),
876
+ documentTitle: text(info["documentTitle"]),
877
+ revisionId: text(form["revisionId"]),
878
+ responderUri: text(form["responderUri"]),
879
+ editUrl: formId ? editUrl(formId) : undefined,
880
+ });
881
+ },
882
+ },
883
+ {
884
+ name: "list_responses",
885
+ description: "List a Google Form's submitted responses, each answer labeled with its question's title in form order. Long answers are cut; get_response reads one whole.",
886
+ annotations: readOnly,
887
+ inputSchema: input({
888
+ formId: FORM_ID_PROPERTY,
889
+ submittedAfter: {
890
+ type: "string",
891
+ pattern: RFC3339_UTC,
892
+ description: "Only responses submitted after this UTC instant (exclusive), e.g. 2026-10-01T00:00:00Z or the newest lastSubmittedTime already read.",
893
+ },
894
+ limit: LIMIT_PROPERTY,
895
+ cursor: cursorProperty("formId and submittedAfter"),
896
+ }, ["formId"]),
897
+ outputSchema: {
898
+ type: "object",
899
+ properties: {
900
+ formId: { type: "string" },
901
+ responses: { type: "array", items: RESPONSE_SCHEMA },
902
+ page: PAGE_SCHEMA,
903
+ },
904
+ required: ["formId", "responses", "page"],
905
+ },
906
+ handler: async (args, ctx) => {
907
+ const formId = args["formId"];
908
+ const limit = args["limit"] ?? DEFAULT_PAGE_SIZE;
909
+ const digest = await digestOf(["list_responses", formId, args["submittedAfter"] ?? null]);
910
+ // A cursor names Google's page (its token and size) and, when a page
911
+ // ended early to stay inside its byte budget, the last response it
912
+ // returned and a fingerprint of that Google page's ordered response
913
+ // ids. The next call re-reads the page and continues strictly after
914
+ // that response only if the page is exactly as it was: Google promises
915
+ // neither a stable order nor a snapshot, so a reordered, grown, or
916
+ // shrunk page could repeat, skip, or cycle, and is refused instead.
917
+ // Moving to Google's next page needs none of this: its token is Google's.
918
+ const resume = typeof args["cursor"] === "string"
919
+ ? decodeCursor(args["cursor"], digest, ["d", "t", "a", "h", "n"], (c) => (c["t"] === null || typeof c["t"] === "string") &&
920
+ ((c["a"] === null && c["h"] === null) ||
921
+ (typeof c["a"] === "string" && c["a"] !== "" && typeof c["h"] === "string")) &&
922
+ isIndex(c["n"]) &&
923
+ c["n"] >= 1 &&
924
+ c["n"] <= MAX_PAGE_SIZE, "with the same formId and submittedAfter")
925
+ : undefined;
926
+ const token = resume?.["t"] ?? null;
927
+ const size = resume?.["n"] ?? limit;
928
+ const anchor = resume?.["a"] ?? null;
929
+ const fingerprint = resume?.["h"] ?? null;
930
+ // The form labels the answers. Both reads are one person's view, so
931
+ // either failing fails the call rather than returning bare ids.
932
+ const [form, listing] = await Promise.all([
933
+ readForm(formId, ctx),
934
+ client.json({
935
+ method: "GET",
936
+ path: `${formPath(formId)}/responses`,
937
+ query: {
938
+ filter: args["submittedAfter"] ? `timestamp > ${args["submittedAfter"]}` : undefined,
939
+ pageSize: size,
940
+ pageToken: token ?? undefined,
941
+ },
942
+ }, ctx),
943
+ ]);
944
+ const labels = questionLabels(form);
945
+ const page = asRecord(listing);
946
+ const rows = asArray(page["responses"]);
947
+ const idOf = (row) => text(asRecord(row)["responseId"]);
948
+ const pageHash = await digestOf(["page", ...rows.map((row) => idOf(row) ?? null)]);
949
+ let start = 0;
950
+ if (anchor !== null) {
951
+ const at = rows.findIndex((row) => idOf(row) === anchor);
952
+ if (fingerprint !== pageHash || at < 0) {
953
+ throw new ConnectorCallError("conflict", "Google's page of responses changed since this cursor was issued (reordered, or responses added or removed), so continuing could repeat or skip some. Start again without cursor, or pass the newest lastSubmittedTime already read as submittedAfter.");
954
+ }
955
+ start = at + 1;
956
+ }
957
+ const remedy = "get_response reads it whole";
958
+ const responses = [];
959
+ let used = jsonBytes({ formId, responses: [], page: { hasMore: true, nextCursor: null } }) + PAGE_RESERVE_BYTES;
960
+ for (let index = start; index < rows.length; index += 1) {
961
+ const room = RESULT_BUDGET_BYTES - used;
962
+ let response = projectResponse(rows[index], labels, LIST_ANSWER_CHARS, remedy);
963
+ if (jsonBytes(response) + 1 > room) {
964
+ if (responses.length > 0)
965
+ break;
966
+ response = fitResponse(rows[index], labels, [LIST_ANSWER_CHARS, 200], remedy, room - 1);
967
+ }
968
+ responses.push(response);
969
+ used += jsonBytes(response) + 1;
970
+ }
971
+ const googleNext = text(page["nextPageToken"]) ?? null;
972
+ const build = () => {
973
+ const last = start + responses.length - 1;
974
+ // Rows left on Google's page: continue after the last one returned.
975
+ // A page that ended on an anchor with nothing after it has no
976
+ // response of its own to anchor on, and moves to Google's next page.
977
+ let next;
978
+ if (start + responses.length < rows.length && responses.length > 0) {
979
+ const after = idOf(rows[last]);
980
+ if (after === undefined) {
981
+ throw new ConnectorCallError("connector_call_failed", "Google returned a response without an id, so this page cannot be continued safely.", { retryable: false });
982
+ }
983
+ next = { d: digest, t: token, a: after, h: pageHash, n: size };
984
+ }
985
+ else if (googleNext) {
986
+ next = { d: digest, t: googleNext, a: null, h: null, n: limit };
987
+ }
988
+ const nextCursor = next ? encodeCursor(next) : null;
989
+ return { formId, responses, page: { hasMore: nextCursor !== null, nextCursor } };
990
+ };
991
+ let result = build();
992
+ while (jsonBytes(result) > RESULT_BUDGET_BYTES && responses.length > 1) {
993
+ responses.pop();
994
+ result = build();
995
+ }
996
+ if (jsonBytes(result) > RESULT_BUDGET_BYTES && responses.length === 1) {
997
+ responses[0] = fitResponse(rows[start], labels, [LIST_ANSWER_CHARS, 200], remedy, RESULT_BUDGET_BYTES - jsonBytes({ ...result, responses: [] }) - 1);
998
+ result = build();
999
+ }
1000
+ if (jsonBytes(result) > RESULT_BUDGET_BYTES) {
1001
+ throw new ConnectorCallError("connector_call_failed", "A response on this page cannot be listed inside one result, even cut; read it with get_response and questionIds.", { retryable: false });
1002
+ }
1003
+ return result;
1004
+ },
1005
+ },
1006
+ {
1007
+ name: "get_response",
1008
+ description: "Get one Google Form response whole, each answer labeled with its question's title, plus file ids, quiz grades, and feedback. questionIds narrows a large one.",
1009
+ annotations: readOnly,
1010
+ inputSchema: input({
1011
+ formId: FORM_ID_PROPERTY,
1012
+ responseId: idProperty("Response id from list_responses."),
1013
+ questionIds: {
1014
+ type: "array",
1015
+ minItems: 1,
1016
+ maxItems: MAX_QUESTION_IDS,
1017
+ uniqueItems: true,
1018
+ items: idProperty("Question id from get_form, or from omittedQuestionIds."),
1019
+ description: "Return only these questions' answers, for a response too large to read whole in one result.",
1020
+ },
1021
+ }, ["formId", "responseId"]),
1022
+ outputSchema: { ...RESPONSE_SCHEMA, required: ["responseId", "answers"] },
1023
+ handler: async (args, ctx) => {
1024
+ const formId = args["formId"];
1025
+ const [form, response] = await Promise.all([
1026
+ readForm(formId, ctx),
1027
+ client.json({ method: "GET", path: `${formPath(formId)}/responses/${encodeURIComponent(args["responseId"])}` }, ctx),
1028
+ ]);
1029
+ const wanted = Array.isArray(args["questionIds"]) ? new Set(args["questionIds"]) : undefined;
1030
+ const record = asRecord(response);
1031
+ const narrowed = wanted
1032
+ ? {
1033
+ ...record,
1034
+ answers: Object.fromEntries(Object.entries(asRecord(record["answers"])).filter(([key, answer]) => wanted.has(text(asRecord(answer)["questionId"]) ?? key))),
1035
+ }
1036
+ : record;
1037
+ // Whole when it fits; otherwise cut, then narrowed, with the ids left
1038
+ // out named so a call with questionIds reads them.
1039
+ const result = fitResponse(narrowed, questionLabels(form), [undefined, MAX_ANSWER_CHARS, LIST_ANSWER_CHARS], "more than one result carries; pass questionIds to read fewer answers, or read it in Forms", RESULT_BUDGET_BYTES);
1040
+ if (jsonBytes(result) > RESULT_BUDGET_BYTES) {
1041
+ throw new ConnectorCallError("invalid_args", "This response cannot be returned inside one result even with its answers cut; pass fewer questionIds.");
1042
+ }
1043
+ return result;
1044
+ },
1045
+ },
1046
+ {
1047
+ name: "update_form_info",
1048
+ description: "Replace a Google Form's title, description, or both; what is omitted is kept. The old text is gone. Questions and settings need batch_update_form.",
1049
+ // Destructive: the title or description it replaces is not recoverable
1050
+ // through this connection.
1051
+ annotations: { readOnlyHint: false, destructiveHint: true },
1052
+ inputSchema: input({
1053
+ formId: FORM_ID_PROPERTY,
1054
+ title: {
1055
+ type: "string",
1056
+ minLength: 1,
1057
+ maxLength: 1_000,
1058
+ description: "New title responders see; omit to keep it.",
1059
+ },
1060
+ description: {
1061
+ type: "string",
1062
+ maxLength: 20_000,
1063
+ description: "New description; an empty string clears it. Omit to keep it.",
1064
+ },
1065
+ requiredRevisionId: { ...REVISION_PROPERTY, description: `Optional. ${REVISION_PROPERTY.description}` },
1066
+ }, ["formId"]),
1067
+ outputSchema: {
1068
+ type: "object",
1069
+ properties: { formId: { type: "string" }, revisionId: { type: "string" } },
1070
+ required: ["formId"],
1071
+ },
1072
+ handler: async (args, ctx) => {
1073
+ const fields = ["title", "description"].filter((field) => args[field] !== undefined);
1074
+ if (fields.length === 0) {
1075
+ throw new ConnectorCallError("invalid_args", "update_form_info needs a title, a description, or both.");
1076
+ }
1077
+ const { formId, revisionId } = await batch(args["formId"], [
1078
+ {
1079
+ updateFormInfo: {
1080
+ info: compact({ title: args["title"], description: args["description"] }),
1081
+ updateMask: fields.join(","),
1082
+ },
1083
+ },
1084
+ ], args["requiredRevisionId"], ctx);
1085
+ return compact({ formId, revisionId });
1086
+ },
1087
+ },
1088
+ {
1089
+ name: "batch_update_form",
1090
+ description: "Apply Forms API batchUpdate requests to a Google Form, all or none: create, update, move, or delete items, and change info or quiz settings. Always needs approval.",
1091
+ // Destructive whatever it carries: a deleteItem or an updateItem loses
1092
+ // what it replaces, and the requests are Google's to interpret.
1093
+ annotations: { readOnlyHint: false, destructiveHint: true },
1094
+ inputSchema: input({
1095
+ formId: FORM_ID_PROPERTY,
1096
+ requests: {
1097
+ type: "array",
1098
+ minItems: 1,
1099
+ maxItems: MAX_REQUESTS,
1100
+ items: REQUEST_SCHEMA,
1101
+ description: `1 to ${MAX_REQUESTS} Forms API Request objects, each exactly one kind, applied in order as Google documents them. Locations are item indexes.`,
1102
+ },
1103
+ requiredRevisionId: REVISION_PROPERTY,
1104
+ }, ["formId", "requests", "requiredRevisionId"]),
1105
+ outputSchema: {
1106
+ type: "object",
1107
+ properties: {
1108
+ formId: { type: "string" },
1109
+ revisionId: { type: "string" },
1110
+ replies: {
1111
+ type: "array",
1112
+ items: {
1113
+ type: "object",
1114
+ properties: {
1115
+ createdItemId: { type: "string" },
1116
+ createdQuestionIds: { type: "array", items: { type: "string" } },
1117
+ },
1118
+ },
1119
+ },
1120
+ truncated: { type: "boolean" },
1121
+ note: { type: "string" },
1122
+ replyCount: { type: "integer" },
1123
+ createdItemCount: { type: "integer" },
1124
+ },
1125
+ required: ["formId", "replies"],
1126
+ },
1127
+ handler: (args, ctx) => batch(args["formId"], args["requests"], args["requiredRevisionId"], ctx),
1128
+ },
1129
+ ];
1130
+ }
1131
+ // --- Guide ------------------------------------------------------------------------
1132
+ function usageGuide(purpose, instructions) {
1133
+ const extra = instructions?.trim();
1134
+ return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${RESULT_BUDGET_BYTES / 1024}${skill.fragments.guide_2}${LIST_ANSWER_CHARS}${skill.fragments.guide_3}${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
1135
+ }
1136
+ // --- Construction -----------------------------------------------------------------
1137
+ /**
1138
+ * A maintained Google Forms connection acting as each signed-in Workspace
1139
+ * user through a delegated service account.
1140
+ *
1141
+ * Setup, once per Workspace (shared with every Workspace provider):
1142
+ *
1143
+ * 1. In a Google Cloud project, enable the **Google Forms API**.
1144
+ * 2. Create a service account. Grant it no IAM roles: delegation, not project
1145
+ * permission, is what reaches users' forms.
1146
+ * 3. Create a JSON key for it. If key creation is refused, the organization
1147
+ * policy `iam.disableServiceAccountKeyCreation` is enforced; an org policy
1148
+ * administrator can override it for this project alone.
1149
+ * 4. Copy the service account's numeric **client ID** (the key's `client_id`).
1150
+ * 5. As a Workspace super admin: Admin console → Security → Access and data
1151
+ * control → API controls → Manage Domain Wide Delegation → Add new (or edit
1152
+ * the entry another Workspace provider already made for this client ID, and
1153
+ * append to its scopes). Paste the client ID and exactly these scopes,
1154
+ * comma-separated ({@link FORMS_SCOPES}):
1155
+ * `https://www.googleapis.com/auth/forms.body,https://www.googleapis.com/auth/forms.responses.readonly`.
1156
+ * A new grant can take up to 24 hours to apply.
1157
+ * 6. Configure inbound auth: a `subject` function is never asked about an
1158
+ * open deployment's anonymous requests, which fail `auth_required`.
1159
+ * Then give the deployment the key as a secret and map callers to accounts:
1160
+ *
1161
+ * ```ts
1162
+ * forms("forms", {
1163
+ * purpose: "Event registration forms and their responses",
1164
+ * serviceAccount: env.GOOGLE_SERVICE_ACCOUNT_JSON,
1165
+ * subject: (identity) => accounts[identity.principal?.id ?? ""],
1166
+ * });
1167
+ * ```
1168
+ *
1169
+ * Listing forms needs Drive, which this connection does not request; pair it
1170
+ * with a Drive connection for that. `create_form` is an additive write the
1171
+ * host approves through call_destructive_tool or a trusted program;
1172
+ * `update_form_info` and `batch_update_form` are destructive.
1173
+ */
1174
+ export const forms = asProviderFactory({
1175
+ name: "forms",
1176
+ title: "Google Forms",
1177
+ kind: "api",
1178
+ readme: "Google Forms",
1179
+ bundle: {
1180
+ "baselineGzip": 26161,
1181
+ "maxGzip": 86161,
1182
+ "note": "./providers/forms starts at 26,161 B gzip (#684): the same class as ./providers/gmail — a hand-written api() surface over the shared Workspace delegation layer, with no OAuth client and no MCP SDK. The cap uses the existing baseline + 60,000 B policy.",
1183
+ },
1184
+ skill,
1185
+ options: GOOGLE_WORKSPACE_OPTIONS,
1186
+ create: formsConnector,
1187
+ });
1188
+ function formsConnector(id, options) {
1189
+ const connection = workspaceConnection("forms", options);
1190
+ const client = googleWorkspaceClient({
1191
+ provider: "Google Forms",
1192
+ api: "Google Forms API",
1193
+ baseUrl: options.baseUrl?.trim() || FORMS_API_BASE_URL,
1194
+ scopes: FORMS_SCOPES,
1195
+ maxResponseBytes: FORMS_MAX_RESPONSE_BYTES,
1196
+ // A form is a Drive file: Google answers 404 alike for one that does not
1197
+ // exist and one this user cannot open, so a 404 is not proof of absence.
1198
+ notFound: "ambiguous",
1199
+ connection,
1200
+ });
1201
+ return api(id, {
1202
+ ...defined({
1203
+ callAdmission: options.callAdmission,
1204
+ maxResultBytes: options.maxResultBytes,
1205
+ }),
1206
+ title: options.title ?? "Google Forms",
1207
+ description: `Google Forms as the signed-in Workspace user: read and edit forms, read responses — ${connection.purpose}`,
1208
+ usageGuide: {
1209
+ content: usageGuide(connection.purpose, options.instructions),
1210
+ summary: "Each caller's own forms: questions by id, labeled responses, revision-checked batch edits.",
1211
+ // Required: whose forms they are, that Drive lists them, and the
1212
+ // revision-checked edit sequence are conventions no schema can carry.
1213
+ required: true,
1214
+ },
1215
+ tools: tools(client),
1216
+ });
1217
+ }