@zackbart/connecta 0.28.1 → 0.29.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 (319) hide show
  1. package/CHANGELOG.md +788 -5
  2. package/README.md +62 -42
  3. package/bin/connecta.mjs +110 -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/auth/clerk-sdk/client.d.ts +65 -0
  12. package/dist/auth/clerk-sdk/client.js +5 -0
  13. package/dist/auth/clerk-sdk/generated.js +1845 -0
  14. package/dist/auth/clerk-transport.js +19 -0
  15. package/dist/auth/clerk.d.ts +17 -7
  16. package/dist/auth/clerk.js +189 -107
  17. package/dist/auth/cloudflare-access.js +25 -26
  18. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  19. package/dist/auth/downstream-client-metadata.js +133 -0
  20. package/dist/auth/downstream-oauth.js +1584 -1580
  21. package/dist/auth/downstream-revocation.js +106 -0
  22. package/dist/auth/oauth-token-response.js +231 -0
  23. package/dist/auth/oauth-v2-migration.js +116 -0
  24. package/dist/auth/static-oauth.js +93 -71
  25. package/dist/auth-elicitation.js +283 -0
  26. package/dist/branding.d.ts +8 -16
  27. package/dist/branding.js +26 -59
  28. package/dist/byte-read-response.js +39 -0
  29. package/dist/call-admission.js +12 -16
  30. package/dist/catalog-cache.js +739 -0
  31. package/dist/catalog-drift.d.ts +50 -15
  32. package/dist/catalog-drift.js +365 -128
  33. package/dist/catalog-fingerprint.js +2 -4
  34. package/dist/catalog-freshness.js +13 -0
  35. package/dist/catalog-limits.js +2 -2
  36. package/dist/catalog-service.js +368 -224
  37. package/dist/catalog.js +43 -84
  38. package/dist/concurrency.js +3 -4
  39. package/dist/config-defaults.d.ts +39 -0
  40. package/dist/config-defaults.js +46 -0
  41. package/dist/config-schema.d.ts +181 -0
  42. package/dist/config-schema.js +435 -0
  43. package/dist/config-value-sources.js +102 -0
  44. package/dist/config.d.ts +631 -0
  45. package/dist/config.js +503 -0
  46. package/dist/connector-access.d.ts +1 -0
  47. package/dist/connector-access.js +13 -15
  48. package/dist/connector-caller.d.ts +8 -0
  49. package/dist/connector-caller.js +15 -6
  50. package/dist/connector-scope.js +1 -1
  51. package/dist/connectors/api-connector.d.ts +59 -7
  52. package/dist/connectors/api-connector.js +106 -19
  53. package/dist/connectors/api.js +4 -1
  54. package/dist/connectors/guarded-fetch.d.ts +144 -0
  55. package/dist/connectors/guarded-fetch.js +84 -35
  56. package/dist/connectors/negotiation-cache.js +46 -0
  57. package/dist/connectors/option-shapes.js +27 -0
  58. package/dist/connectors/remote-mcp.d.ts +30 -4
  59. package/dist/connectors/remote-mcp.js +1774 -320
  60. package/dist/connectors/resource-uri.js +335 -0
  61. package/dist/credential-contract.d.ts +10 -0
  62. package/dist/credential-rules.js +1 -3
  63. package/dist/credential-url.js +108 -0
  64. package/dist/credentials.d.ts +6 -1
  65. package/dist/credentials.js +27 -18
  66. package/dist/d1.d.ts +130 -0
  67. package/dist/d1.js +290 -0
  68. package/dist/describe-config.d.ts +165 -0
  69. package/dist/describe-config.js +315 -0
  70. package/dist/described.js +106 -0
  71. package/dist/downstream-input-context.js +119 -0
  72. package/dist/downstream-input.js +323 -0
  73. package/dist/errors.d.ts +97 -20
  74. package/dist/errors.js +158 -80
  75. package/dist/execute.js +453 -305
  76. package/dist/executor-admission.d.ts +1 -0
  77. package/dist/executor-admission.js +11 -15
  78. package/dist/executor-contract.d.ts +20 -0
  79. package/dist/executor-contract.js +48 -0
  80. package/dist/executor-result.js +24 -9
  81. package/dist/executors/quickjs-child.js +3 -4
  82. package/dist/executors/quickjs-runtime.js +147 -59
  83. package/dist/executors/quickjs.js +29 -35
  84. package/dist/favicon.js +1 -2
  85. package/dist/fix-prompt.js +1 -3
  86. package/dist/guest-runtime.js +188 -0
  87. package/dist/guest-types.d.ts +176 -0
  88. package/dist/guest-types.js +1 -0
  89. package/dist/html-security.js +24 -0
  90. package/dist/identity.js +1 -3
  91. package/dist/inbound-credential.js +52 -0
  92. package/dist/index.d.ts +24 -256
  93. package/dist/index.js +65 -478
  94. package/dist/invocation-auth.js +69 -0
  95. package/dist/invocation.js +230 -73
  96. package/dist/json-schema.d.ts +1 -1
  97. package/dist/mcp-client-context.js +35 -0
  98. package/dist/mcp-result.js +11 -7
  99. package/dist/meta-output.js +76 -0
  100. package/dist/meta-tool-names.d.ts +2 -0
  101. package/dist/meta-tool-names.js +3 -0
  102. package/dist/meta-tools.js +285 -209
  103. package/dist/module-contracts.d.ts +8 -23
  104. package/dist/node.d.ts +0 -2
  105. package/dist/node.js +2 -2
  106. package/dist/oauth-callback-outcome.js +1 -3
  107. package/dist/oauth-handoff.js +117 -0
  108. package/dist/oauth-sealing.js +20 -0
  109. package/dist/operator-record.d.ts +137 -0
  110. package/dist/operator-record.js +420 -0
  111. package/dist/operator-ui/assets.js +31 -0
  112. package/dist/operator-ui/contract.d.ts +72 -0
  113. package/dist/operator-ui/contract.js +1 -0
  114. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  115. package/dist/operator-ui/generated.js +5 -4
  116. package/dist/operator-ui/model.d.ts +12 -87
  117. package/dist/operator-ui/model.js +2 -9
  118. package/dist/operator-ui/setup-commands.js +1 -3
  119. package/dist/operator-ui/view.d.ts +323 -0
  120. package/dist/operator-ui/view.js +62 -189
  121. package/dist/page-styles.js +2 -3
  122. package/dist/program-source.js +14 -2
  123. package/dist/{exempt-writes.js → program-writes.js} +55 -18
  124. package/dist/provider.d.ts +142 -0
  125. package/dist/provider.js +187 -0
  126. package/dist/providers/_shared/google/result-size.js +57 -0
  127. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  128. package/dist/providers/_shared/google/service-account.js +500 -0
  129. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  130. package/dist/providers/_shared/google/workspace.js +518 -0
  131. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  132. package/dist/providers/basecamp/index.js +997 -0
  133. package/dist/providers/basecamp/skill.generated.js +9 -0
  134. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  135. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  136. package/dist/providers/breeze/skill.generated.js +9 -0
  137. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  138. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  139. package/dist/providers/ccb/skill.generated.js +9 -0
  140. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  141. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  142. package/dist/providers/cloudflare/skill.generated.js +10 -0
  143. package/dist/providers/docs/index.d.ts +54 -0
  144. package/dist/providers/docs/index.js +1237 -0
  145. package/dist/providers/docs/skill.generated.js +9 -0
  146. package/dist/providers/drive/index.d.ts +53 -0
  147. package/dist/providers/drive/index.js +1523 -0
  148. package/dist/providers/drive/skill.generated.js +9 -0
  149. package/dist/providers/forms/index.d.ts +54 -0
  150. package/dist/providers/forms/index.js +1217 -0
  151. package/dist/providers/forms/skill.generated.js +11 -0
  152. package/dist/providers/github/auth.d.ts +32 -0
  153. package/dist/providers/github/auth.js +361 -0
  154. package/dist/providers/github/hosted.js +218 -0
  155. package/dist/providers/github/index.d.ts +13 -0
  156. package/dist/providers/github/index.js +140 -0
  157. package/dist/providers/github/key.js +100 -0
  158. package/dist/providers/github/rest.js +321 -0
  159. package/dist/providers/github/scope.d.ts +29 -0
  160. package/dist/providers/github/scope.js +124 -0
  161. package/dist/providers/github/skill.generated.js +8 -0
  162. package/dist/providers/gmail/index.d.ts +51 -0
  163. package/dist/providers/gmail/index.js +1436 -0
  164. package/dist/providers/gmail/skill.generated.js +9 -0
  165. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  166. package/dist/providers/linear/index.js +352 -0
  167. package/dist/providers/linear/skill.generated.js +8 -0
  168. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  169. package/dist/providers/mixpanel/index.js +416 -0
  170. package/dist/providers/mixpanel/skill.generated.js +9 -0
  171. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  172. package/dist/providers/{notion.js → notion/index.js} +226 -239
  173. package/dist/providers/notion/skill.generated.js +10 -0
  174. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  175. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  176. package/dist/providers/overflow/skill.generated.js +8 -0
  177. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  178. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  179. package/dist/providers/planning-center/skill.generated.js +10 -0
  180. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  181. package/dist/providers/revenuecat/index.js +557 -0
  182. package/dist/providers/revenuecat/skill.generated.js +11 -0
  183. package/dist/providers/sheets/index.d.ts +54 -0
  184. package/dist/providers/sheets/index.js +1237 -0
  185. package/dist/providers/sheets/skill.generated.js +10 -0
  186. package/dist/providers/slides/index.d.ts +53 -0
  187. package/dist/providers/slides/index.js +2645 -0
  188. package/dist/providers/slides/skill.generated.js +9 -0
  189. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  190. package/dist/providers/stripe/index.js +266 -0
  191. package/dist/providers/stripe/skill.generated.js +11 -0
  192. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  193. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  194. package/dist/providers/tithely/skill.generated.js +8 -0
  195. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  196. package/dist/providers/vercel/index.js +1038 -0
  197. package/dist/providers/vercel/mcp-ownership.js +212 -0
  198. package/dist/providers/vercel/skill.generated.js +11 -0
  199. package/dist/registry.d.ts +108 -171
  200. package/dist/registry.js +513 -925
  201. package/dist/request-state.js +21 -0
  202. package/dist/resource-read.js +100 -0
  203. package/dist/result-shapes.js +17 -49
  204. package/dist/retired-toolkits.js +1 -1
  205. package/dist/routes/access-tokens.js +3 -6
  206. package/dist/routes/activity-context.js +21 -0
  207. package/dist/routes/activity-disclosure.js +40 -0
  208. package/dist/routes/activity.js +59 -21
  209. package/dist/routes/actor-labels.js +3 -50
  210. package/dist/routes/connect.js +221 -0
  211. package/dist/routes/credentials.js +15 -27
  212. package/dist/routes/mcp.js +356 -130
  213. package/dist/routes/oauth-client-metadata.js +15 -0
  214. package/dist/routes/oauth-management.js +45 -107
  215. package/dist/routes/oauth.js +110 -57
  216. package/dist/routes/operator.js +3 -3
  217. package/dist/routes/shared.d.ts +25 -64
  218. package/dist/routes/shared.js +160 -80
  219. package/dist/routes/skills.js +30 -0
  220. package/dist/routes/ui-config.js +225 -0
  221. package/dist/routes/ui-data.js +40 -35
  222. package/dist/routes/ui.js +66 -56
  223. package/dist/runtime/admission.js +4 -2
  224. package/dist/runtime/call-admission.js +3 -1
  225. package/dist/runtime/connector-scope.js +1 -4
  226. package/dist/runtime/run.js +10 -39
  227. package/dist/runtime/services.js +12 -117
  228. package/dist/runtime/shared-read.js +1 -3
  229. package/dist/runtime/storage.js +8 -1
  230. package/dist/sent-secrets.d.ts +52 -0
  231. package/dist/sent-secrets.js +474 -0
  232. package/dist/server.js +28 -71
  233. package/dist/skills.js +406 -174
  234. package/dist/sqlite.d.ts +48 -0
  235. package/dist/sqlite.js +166 -0
  236. package/dist/storage/keys.js +428 -0
  237. package/dist/storage/memory.js +7 -0
  238. package/dist/storage/sql.d.ts +66 -0
  239. package/dist/storage/sql.js +534 -0
  240. package/dist/timeout.js +4 -2
  241. package/dist/tool-name.js +17 -0
  242. package/dist/tool-safety.d.ts +9 -35
  243. package/dist/tool-safety.js +20 -40
  244. package/dist/types.d.ts +320 -54
  245. package/dist/types.js +0 -1
  246. package/dist/ui.d.ts +7 -19
  247. package/dist/ui.js +76 -90
  248. package/dist/url-safety.js +3 -6
  249. package/dist/usage-guide.js +137 -0
  250. package/dist/validate.d.ts +7 -0
  251. package/dist/validate.js +309 -47
  252. package/dist/version.d.ts +1 -7
  253. package/dist/version.js +3 -7
  254. package/dist/worker.d.ts +11 -0
  255. package/dist/worker.js +434 -0
  256. package/documentation/architecture.md +664 -231
  257. package/documentation/auth.md +936 -546
  258. package/documentation/code-mode.md +501 -312
  259. package/documentation/meta-tools.md +449 -200
  260. package/documentation/operator-tests.md +93 -0
  261. package/documentation/provider-migration-0.29.md +60 -0
  262. package/examples/worker/AGENTS.md +34 -12
  263. package/examples/worker/README.md +286 -255
  264. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  265. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  266. package/examples/worker/src/connecta.config.ts +151 -0
  267. package/examples/worker/src/index.ts +14 -197
  268. package/examples/worker/wrangler.jsonc +15 -35
  269. package/package.json +120 -54
  270. package/templates/node/.dockerignore +1 -2
  271. package/templates/node/.env.example +20 -21
  272. package/templates/node/AGENTS.md +21 -17
  273. package/templates/node/Dockerfile +3 -5
  274. package/templates/node/README.md +133 -58
  275. package/templates/node/docker-compose.yml +11 -12
  276. package/templates/node/package.json +4 -2
  277. package/templates/node/src/connecta.config.ts +119 -0
  278. package/templates/node/src/index.ts +9 -154
  279. package/templates/node/src/provision-token.ts +17 -0
  280. package/AGENTS.md +0 -170
  281. package/dist/artifacts/connector.d.ts +0 -58
  282. package/dist/artifacts/connector.js +0 -730
  283. package/dist/artifacts/document.js +0 -86
  284. package/dist/artifacts/guide.js +0 -140
  285. package/dist/artifacts/html-scan.js +0 -329
  286. package/dist/artifacts/json.js +0 -78
  287. package/dist/artifacts/kv-store.d.ts +0 -20
  288. package/dist/artifacts/kv-store.js +0 -140
  289. package/dist/artifacts/markdown.js +0 -478
  290. package/dist/artifacts/module.d.ts +0 -37
  291. package/dist/artifacts/module.js +0 -66
  292. package/dist/artifacts/operations.d.ts +0 -197
  293. package/dist/artifacts/operations.js +0 -1002
  294. package/dist/artifacts/refresh.d.ts +0 -55
  295. package/dist/artifacts/refresh.js +0 -236
  296. package/dist/artifacts/routes.js +0 -206
  297. package/dist/artifacts/types.d.ts +0 -214
  298. package/dist/artifacts/types.js +0 -22
  299. package/dist/artifacts/validate.d.ts +0 -62
  300. package/dist/artifacts/validate.js +0 -651
  301. package/dist/artifacts.d.ts +0 -6
  302. package/dist/artifacts.js +0 -9
  303. package/dist/auth/bearer.d.ts +0 -11
  304. package/dist/auth/bearer.js +0 -50
  305. package/dist/providers/basecamp.js +0 -445
  306. package/dist/providers/linear.js +0 -217
  307. package/dist/providers/mixpanel.js +0 -247
  308. package/dist/providers/revenuecat.js +0 -317
  309. package/dist/providers/stripe.js +0 -253
  310. package/dist/providers/vercel.js +0 -1253
  311. package/dist/storage/file.d.ts +0 -15
  312. package/dist/storage/file.js +0 -455
  313. package/ethos.md +0 -93
  314. package/examples/worker/src/cloudflare-kv.ts +0 -43
  315. package/examples/worker/src/d1-activity-row.ts +0 -155
  316. package/examples/worker/src/d1-activity.ts +0 -147
  317. package/examples/worker/src/d1-storage.ts +0 -138
  318. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  319. package/templates/node/src/file-activity.ts +0 -182
@@ -0,0 +1,1237 @@
1
+ import { skill } from "./skill.generated.js";
2
+ /**
3
+ * Google Sheets as the signed-in Workspace user: read a spreadsheet's shape
4
+ * and its values, write, append, and clear values, create spreadsheets and
5
+ * sheets, and reach the rest of the editor through one approval-gated
6
+ * `batchUpdate`. Hand-written against the Sheets API v4 reference
7
+ * (https://developers.google.com/workspace/sheets/api/reference/rest).
8
+ *
9
+ * Whose spreadsheets. Access is a service account with domain-wide delegation
10
+ * (`src/providers/_shared/google/workspace.ts`): deployment config maps the admitted
11
+ * identity to a Workspace address, and each call mints, or reuses, a token as
12
+ * that user. There is no per-user consent step, no operator credential slot,
13
+ * and no argument that names an account. Sheets has no `users/me` root to
14
+ * confine paths beneath, so the token is the confinement: a spreadsheet id
15
+ * reaches exactly what that user could open in the Sheets UI, and nothing
16
+ * more.
17
+ *
18
+ * What it will not do. Finding a spreadsheet by name is Google Drive's job —
19
+ * the Sheets API has no list method — so this connection starts from an id.
20
+ * It never moves, shares, or trashes a file, which are Drive's too.
21
+ *
22
+ * The safety classes. Reads are read-only. Creating a spreadsheet, adding a
23
+ * sheet, and appending rows lose nothing and are additive writes. Writing over
24
+ * a range, clearing one, and the raw `batchUpdate` — whose requests can delete
25
+ * sheets, rows, and protections — are destructive. Append is pinned to
26
+ * `INSERT_ROWS`: Google's `OVERWRITE` writes over whatever sits below the
27
+ * table, and a tool that could do that is not additive.
28
+ *
29
+ * Drift. Google publishes a credential-free Discovery document per API.
30
+ * `src/providers/sheets/drift.json` records the seven methods the tools
31
+ * call, and `npm run providers:check -- --provider sheets` reports a touched
32
+ * contract that moved or a method that stopped accepting the scope below.
33
+ */
34
+ import { apiConnector as api, defined } from "../../connectors/api-connector.js";
35
+ import { ConnectorCallError } from "../../errors.js";
36
+ import { googleOutcomeOf, GOOGLE_WORKSPACE_OPTIONS, googleWorkspaceClient, workspaceConnection, } from "../_shared/google/workspace.js";
37
+ import { clampText, jsonBytes, RESULT_BUDGET_BYTES } from "../_shared/google/result-size.js";
38
+ import { asProviderFactory } from "../../provider.js";
39
+ /**
40
+ * The Sheets API root. Every path the tools send is beneath
41
+ * `/spreadsheets`; the delegated token decides which ones the user can open.
42
+ */
43
+ export const SHEETS_API_BASE_URL = "https://sheets.googleapis.com/v4";
44
+ /**
45
+ * Exactly the scope this connection requests, and what the Admin console's
46
+ * domain-wide delegation entry must add for it. One scope reads and writes
47
+ * every spreadsheet the user can open; Google offers no narrower one that
48
+ * writes.
49
+ */
50
+ export const SHEETS_SCOPES = ["https://www.googleapis.com/auth/spreadsheets"];
51
+ /** Cells per page of get_values: connecta's cap, since Sheets has no paging. */
52
+ const DEFAULT_PAGE_CELLS = 2_000;
53
+ const MAX_PAGE_CELLS = 10_000;
54
+ /**
55
+ * Bytes per page of get_values as serialized, beside the cell count: a page
56
+ * has to be deliverable, and cells say nothing about size — 60 cells of 5,000
57
+ * characters already outgrow the QuickJS executor's host bridge. The default
58
+ * is the Workspace providers' shared result budget, so an execute_code
59
+ * program always receives a default page; unlike that budget's envelope
60
+ * allowance, maxBytes bounds the whole page, fields and cursor included. A
61
+ * direct call_tool caller may ask for more, up to 4 MiB: base64 in the result
62
+ * stash makes that about 5.6 MiB, under the stash's default 8 MiB, so an
63
+ * oversized page still pages through connecta.result rather than being dropped.
64
+ */
65
+ const DEFAULT_PAGE_BYTES = RESULT_BUDGET_BYTES;
66
+ const MAX_PAGE_BYTES = 4 * 1024 * 1024;
67
+ /**
68
+ * Characters a read keeps per cell by default. A cell holds at most 50,000
69
+ * (Sheets' own limit), so `maxCellChars` at that ceiling always reads one whole.
70
+ */
71
+ const DEFAULT_CELL_CHARS = 5_000;
72
+ const MAX_CELL_CHARS = 50_000;
73
+ /** Ranges per read or clear. */
74
+ const MAX_RANGES = 20;
75
+ /** Ranges per batch_update_values. */
76
+ const MAX_DATA_RANGES = 100;
77
+ /** Rows and cells one write may carry: connecta's cap, under Google's 10 MB request. */
78
+ const MAX_WRITE_ROWS = 10_000;
79
+ const MAX_WRITE_CELLS = 50_000;
80
+ /** Sheets' own column ceiling (ZZZ). */
81
+ const MAX_COLUMNS = 18_278;
82
+ /** Named ranges a metadata read lists before saying how many it left out. */
83
+ const MAX_NAMED_RANGES = 200;
84
+ /** Requests per raw batchUpdate. */
85
+ const MAX_BATCH_REQUESTS = 100;
86
+ /** A wide values read; Google itself recommends payloads under 2 MB. */
87
+ const SHEETS_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
88
+ function asRecord(value) {
89
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
90
+ }
91
+ function asArray(value) {
92
+ return Array.isArray(value) ? value : [];
93
+ }
94
+ function compact(value) {
95
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
96
+ }
97
+ function text(value) {
98
+ return typeof value === "string" && value !== "" ? value : undefined;
99
+ }
100
+ function integer(value) {
101
+ return typeof value === "number" && Number.isInteger(value) ? value : undefined;
102
+ }
103
+ /** A 0-based column index as its A1 letters: 0 → A, 26 → AA. */
104
+ function columnLetters(index) {
105
+ let letters = "";
106
+ for (let rest = index + 1; rest > 0; rest = Math.floor((rest - 1) / 26)) {
107
+ letters = String.fromCharCode(65 + ((rest - 1) % 26)) + letters;
108
+ }
109
+ return letters;
110
+ }
111
+ /**
112
+ * A sheet title as A1, always single-quoted. Bare, a whole-sheet range is
113
+ * indistinguishable from a named range of the same name — and Google resolves
114
+ * the named range first, so `Sheet1` handed to clear_values could clear
115
+ * something else. No named range can contain a quote, so quoted cannot.
116
+ */
117
+ function sheetPrefix(title) {
118
+ return `'${title.replace(/'/g, "''")}'`;
119
+ }
120
+ /**
121
+ * A GridRange as A1, or `undefined` for a shape A1 cannot say. Google leaves
122
+ * a zero index out of the JSON, and a missing start index means "from the
123
+ * first", so both read as 0; a missing end means "to the edge".
124
+ */
125
+ function gridRangeA1(range, title) {
126
+ if (title === undefined)
127
+ return undefined;
128
+ const startRow = integer(range["startRowIndex"]) ?? 0;
129
+ const startColumn = integer(range["startColumnIndex"]) ?? 0;
130
+ const endRow = integer(range["endRowIndex"]);
131
+ const endColumn = integer(range["endColumnIndex"]);
132
+ const prefix = sheetPrefix(title);
133
+ if (endRow === undefined && endColumn === undefined) {
134
+ return startRow === 0 && startColumn === 0 ? prefix : undefined;
135
+ }
136
+ if (endColumn === undefined) {
137
+ return startColumn === 0 ? `${prefix}!${startRow + 1}:${endRow}` : undefined;
138
+ }
139
+ const start = `${columnLetters(startColumn)}${startRow + 1}`;
140
+ return `${prefix}!${start}:${columnLetters(endColumn - 1)}${endRow ?? ""}`;
141
+ }
142
+ function base64Url(bytes) {
143
+ let binary = "";
144
+ for (const byte of bytes)
145
+ binary += String.fromCharCode(byte);
146
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
147
+ }
148
+ /** What a cursor is bound to: everything that decides which cells a page reads. */
149
+ async function readDigest(args) {
150
+ const bound = JSON.stringify([
151
+ args["spreadsheetId"],
152
+ args["ranges"],
153
+ args["valueRenderOption"] ?? null,
154
+ args["dateTimeRenderOption"] ?? null,
155
+ ]);
156
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(bound)));
157
+ return base64Url(hash.subarray(0, 16));
158
+ }
159
+ function encodeCursor(cursor) {
160
+ return base64Url(new TextEncoder().encode(JSON.stringify(cursor)));
161
+ }
162
+ function decodeCursor(value, ranges, digest) {
163
+ const refuse = () => new ConnectorCallError("invalid_args", "cursor is not one get_values returned for this call. Pass page.nextCursor back unchanged, with the same spreadsheetId, ranges, and render options.");
164
+ let parsed;
165
+ try {
166
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
167
+ const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
168
+ parsed = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
169
+ }
170
+ catch {
171
+ throw refuse();
172
+ }
173
+ const cursor = asRecord(parsed);
174
+ const index = integer(cursor["i"]);
175
+ const skip = integer(cursor["s"]);
176
+ if (Object.keys(cursor).length !== 3 ||
177
+ cursor["d"] !== digest ||
178
+ index === undefined ||
179
+ index < 0 ||
180
+ index >= ranges.length ||
181
+ skip === undefined ||
182
+ skip < 0) {
183
+ throw refuse();
184
+ }
185
+ return { d: digest, i: index, s: skip };
186
+ }
187
+ /**
188
+ * The caller's own range past its first `rows` rows, when the range says
189
+ * where it starts: `Sheet1!C5:F100` → `Sheet1!C8:F100`, `B:D` → `B4:D`. A
190
+ * sheet title, a named range, or R1C1 does not, and is resent whole with the
191
+ * rows skipped instead — correct, but it re-reads what earlier pages did.
192
+ */
193
+ function rangeAfter(range, rows) {
194
+ if (rows === 0)
195
+ return range;
196
+ const match = /^(.*!)?([A-Z]+)(\d+)?:([A-Z]+)(\d+)?$/.exec(range);
197
+ if (!match)
198
+ return undefined;
199
+ const [, prefix = "", startColumn, startRow, endColumn, endRow] = match;
200
+ if (startRow === undefined && endRow !== undefined)
201
+ return undefined;
202
+ const first = Number(startRow ?? 1);
203
+ if (endRow !== undefined && first > Number(endRow))
204
+ return undefined;
205
+ const start = first + rows;
206
+ if (endRow !== undefined && start > Number(endRow))
207
+ return undefined;
208
+ return `${prefix}${startColumn}${start}:${endColumn}${endRow ?? ""}`;
209
+ }
210
+ /** A cell as read, cut at `max` characters with a marker saying how to read the rest. */
211
+ function cappedCell(value, max, cut) {
212
+ if (typeof value !== "string" || value.length <= max)
213
+ return value;
214
+ cut.cells += 1;
215
+ return `${value.slice(0, max)}[… ${value.length - max} more characters truncated; read this cell alone with maxCellChars up to ${MAX_CELL_CHARS} for the rest]`;
216
+ }
217
+ /** Refuse a row no page of `budget` bytes can hold, rather than overrun the cap. */
218
+ function rowTooLarge(range, row, needed, budget) {
219
+ return new ConnectorCallError("invalid_args", `Row ${row} of ${range} needs a page of ${needed} bytes with the page's own fields, more than this page's maxBytes of ${budget}; narrow the range's columns or lower maxCellChars${raiseBytes(needed)}.`);
220
+ }
221
+ /** Refuse a page too small for even one range's own fields. */
222
+ function pageTooSmall(range, needed, budget) {
223
+ return new ConnectorCallError("invalid_args", `A page reporting ${range} needs ${needed} bytes before any of its rows, more than this page's maxBytes of ${budget}; shorten the range${raiseBytes(needed)}.`);
224
+ }
225
+ function raiseBytes(needed) {
226
+ if (needed > MAX_PAGE_BYTES)
227
+ return "";
228
+ return needed <= DEFAULT_PAGE_BYTES
229
+ ? `, or raise maxBytes to at least ${needed}`
230
+ : `, or raise maxBytes to at least ${needed} (above ${DEFAULT_PAGE_BYTES}, only for a direct call_tool read: execute_code cannot receive it)`;
231
+ }
232
+ /** Refuse a row no page of `budget` cells can hold, rather than overrun the cap. */
233
+ function rowTooWide(range, row, width, budget) {
234
+ const fix = width <= MAX_PAGE_CELLS
235
+ ? `raise maxCells to at least ${width}, or narrow the range's columns`
236
+ : `narrow the range's columns; a page holds at most ${MAX_PAGE_CELLS} cells`;
237
+ return new ConnectorCallError("invalid_args", `Row ${row} of ${range} has ${width} cells, more than this page's maxCells of ${budget}; ${fix}.`);
238
+ }
239
+ // --- Writing values ---------------------------------------------------------------
240
+ /** Refuse a write past connecta's per-call cell cap before anything is sent. */
241
+ function checkWriteSize(blocks) {
242
+ let cells = 0;
243
+ for (const block of blocks)
244
+ for (const row of block)
245
+ cells += row.length;
246
+ if (cells > MAX_WRITE_CELLS) {
247
+ throw new ConnectorCallError("invalid_args", `This write carries ${cells} cells; connecta sends at most ${MAX_WRITE_CELLS} per call (Google's own request limit is larger). Split it into several calls. Nothing was written.`);
248
+ }
249
+ }
250
+ function projectUpdate(value) {
251
+ const update = asRecord(value);
252
+ return compact({
253
+ updatedRange: text(update["updatedRange"]),
254
+ updatedRows: integer(update["updatedRows"]) ?? 0,
255
+ updatedColumns: integer(update["updatedColumns"]) ?? 0,
256
+ updatedCells: integer(update["updatedCells"]) ?? 0,
257
+ });
258
+ }
259
+ /**
260
+ * Every `Request` kind Sheets' batchUpdate accepts, from the Discovery
261
+ * document's `Request` schema at revision 20260930. A request must set exactly
262
+ * one of them; its body is Google's to validate. Too many to close in the
263
+ * schema without overrunning H7, so the handler checks the kind before
264
+ * anything is sent. A kind Google adds changes the batchUpdate contract digest,
265
+ * so the drift check surfaces it for this list rather than letting it pass.
266
+ */
267
+ const REQUEST_KINDS = new Set([
268
+ "addBanding",
269
+ "addChart",
270
+ "addCommentReply",
271
+ "addConditionalFormatRule",
272
+ "addDataSource",
273
+ "addDimensionGroup",
274
+ "addFilterView",
275
+ "addNamedRange",
276
+ "addProtectedRange",
277
+ "addSheet",
278
+ "addSlicer",
279
+ "addTable",
280
+ "appendCells",
281
+ "appendDimension",
282
+ "autoFill",
283
+ "autoResizeDimensions",
284
+ "cancelDataSourceRefresh",
285
+ "clearBasicFilter",
286
+ "copyPaste",
287
+ "createDeveloperMetadata",
288
+ "cutPaste",
289
+ "deleteBanding",
290
+ "deleteComment",
291
+ "deleteCommentReply",
292
+ "deleteConditionalFormatRule",
293
+ "deleteDataSource",
294
+ "deleteDeveloperMetadata",
295
+ "deleteDimension",
296
+ "deleteDimensionGroup",
297
+ "deleteDuplicates",
298
+ "deleteEmbeddedObject",
299
+ "deleteFilterView",
300
+ "deleteNamedRange",
301
+ "deleteProtectedRange",
302
+ "deleteRange",
303
+ "deleteSheet",
304
+ "deleteTable",
305
+ "duplicateFilterView",
306
+ "duplicateSheet",
307
+ "findReplace",
308
+ "insertComment",
309
+ "insertDimension",
310
+ "insertRange",
311
+ "mergeCells",
312
+ "moveDimension",
313
+ "pasteData",
314
+ "randomizeRange",
315
+ "refreshDataSource",
316
+ "repeatCell",
317
+ "setBasicFilter",
318
+ "setDataValidation",
319
+ "sortRange",
320
+ "textToColumns",
321
+ "trimWhitespace",
322
+ "unmergeCells",
323
+ "updateBanding",
324
+ "updateBorders",
325
+ "updateCells",
326
+ "updateChartSpec",
327
+ "updateCommentPost",
328
+ "updateConditionalFormatRule",
329
+ "updateDataSource",
330
+ "updateDeveloperMetadata",
331
+ "updateDimensionGroup",
332
+ "updateDimensionProperties",
333
+ "updateEmbeddedObjectBorder",
334
+ "updateEmbeddedObjectPosition",
335
+ "updateFilterView",
336
+ "updateNamedRange",
337
+ "updateProtectedRange",
338
+ "updateSheetProperties",
339
+ "updateSlicerSpec",
340
+ "updateSpreadsheetProperties",
341
+ "updateTable",
342
+ ]);
343
+ /** Refuse a raw request that is not exactly one known kind, unsent. */
344
+ function checkRequestKinds(requests) {
345
+ requests.forEach((request, index) => {
346
+ const kinds = Object.keys(asRecord(request));
347
+ if (kinds.length !== 1 || !REQUEST_KINDS.has(kinds[0])) {
348
+ throw new ConnectorCallError("invalid_args", `requests[${index}] must set exactly one Sheets Request kind (addSheet, repeatCell, deleteDimension, …); it sets ${kinds.length === 0 ? "none" : kinds.slice(0, 3).join(", ")}. Nothing was sent.`);
349
+ }
350
+ });
351
+ }
352
+ /**
353
+ * A write whose outcome is unknown — sent, and then anything but an answer
354
+ * Google refused it with: a reply that broke off, no reply, a redirect, or a
355
+ * 5xx after receiving it — told what to read before trying again, for the
356
+ * writes where the shared "re-read before repeating it" has nothing to re-read
357
+ * or where repeating does harm. The advice claims nothing either way; only
358
+ * the client's own message says "probably applied", and only for a 2xx whose
359
+ * body broke off. A refusal, and anything never sent, passes through as the
360
+ * client mapped it.
361
+ */
362
+ async function uncertainWrite(advice, write) {
363
+ try {
364
+ return await write();
365
+ }
366
+ catch (error) {
367
+ const outcome = googleOutcomeOf(error);
368
+ if (!(error instanceof ConnectorCallError) || !outcome?.dispatched || outcome.phase === "refused") {
369
+ throw error;
370
+ }
371
+ throw new ConnectorCallError(error.code, `${error.message} ${advice}`, { retryable: false, cause: error });
372
+ }
373
+ }
374
+ // --- Raw batchUpdate replies -----------------------------------------------------
375
+ /** A string kept in a reply summary, cut so a long one cannot crowd out ids. */
376
+ const SUMMARY_TEXT_BYTES = 256;
377
+ /** The scalar fields of an object — ids, titles, counts — and nothing nested. */
378
+ function scalarsOf(value) {
379
+ const scalars = {};
380
+ for (const [key, field] of Object.entries(asRecord(value))) {
381
+ if (typeof field === "string") {
382
+ scalars[key] = clampText(field, SUMMARY_TEXT_BYTES, (dropped) => `[… ${dropped} more characters]`);
383
+ }
384
+ else if (typeof field === "number" || typeof field === "boolean") {
385
+ scalars[key] = field;
386
+ }
387
+ }
388
+ return Object.keys(scalars).length > 0 ? scalars : undefined;
389
+ }
390
+ /**
391
+ * One reply reduced to its kind and the scalars an agent acts on next: a new
392
+ * sheet's id and title, a chart's id, findReplace's counts. Google's replies
393
+ * put those at the top of the kind's body or one object down (`properties`,
394
+ * `chart`, `namedRange`); arrays and anything deeper are dropped.
395
+ */
396
+ function summarizeReply(reply) {
397
+ const kind = Object.keys(reply)[0];
398
+ if (kind === undefined)
399
+ return {};
400
+ const body = asRecord(reply[kind]);
401
+ const nested = {};
402
+ for (const [key, field] of Object.entries(body)) {
403
+ if (field && typeof field === "object" && !Array.isArray(field))
404
+ nested[key] = scalarsOf(field);
405
+ }
406
+ return compact({ kind, ...scalarsOf(body), ...compact(nested) });
407
+ }
408
+ /**
409
+ * A batchUpdate result that can be delivered. The write has already applied,
410
+ * so whatever does not fit is summarized, never dropped silently and never
411
+ * reported as a failure: replies are kept whole, in order, while the ones
412
+ * after them still fit as summaries; the rest become summaries; and a reply
413
+ * set too large even for that leaves the counts and where to re-read.
414
+ */
415
+ function deliverableReplies(spreadsheetId, replies, requestCount) {
416
+ const whole = { spreadsheetId, replies };
417
+ if (jsonBytes(whole) <= RESULT_BUDGET_BYTES)
418
+ return whole;
419
+ const note = `All ${requestCount} requests applied. Replies too large to return here are cut to their kind, ids, and counts; read the spreadsheet with get_spreadsheet or get_values for the rest. Do not send the requests again.`;
420
+ const summaries = replies.map(summarizeReply);
421
+ // What every reply from index i on costs as a summary, comma included.
422
+ const rest = Array.from({ length: replies.length + 1 }, () => 0);
423
+ for (let index = replies.length - 1; index >= 0; index -= 1) {
424
+ rest[index] = rest[index + 1] + jsonBytes(summaries[index]) + 1;
425
+ }
426
+ let bytes = jsonBytes({ spreadsheetId, replies: [], repliesSummarized: replies.length, note });
427
+ if (bytes + rest[0] <= RESULT_BUDGET_BYTES) {
428
+ const kept = [];
429
+ let summarized = 0;
430
+ replies.forEach((reply, index) => {
431
+ const size = jsonBytes(reply) + 1;
432
+ if (summarized === 0 && bytes + size + rest[index + 1] <= RESULT_BUDGET_BYTES) {
433
+ bytes += size;
434
+ kept.push(reply);
435
+ }
436
+ else {
437
+ summarized += 1;
438
+ kept.push(summaries[index]);
439
+ }
440
+ });
441
+ return { spreadsheetId, replies: kept, repliesSummarized: summarized, note };
442
+ }
443
+ return {
444
+ spreadsheetId,
445
+ replies: [],
446
+ repliesOmitted: replies.length,
447
+ note: `All ${requestCount} requests applied, but their ${replies.length} replies are too large to return even summarized. Read the spreadsheet with get_spreadsheet or get_values to see the result. Do not send the requests again.`,
448
+ };
449
+ }
450
+ // --- Schemas ----------------------------------------------------------------------
451
+ function input(properties, required) {
452
+ return { type: "object", properties, required, additionalProperties: false };
453
+ }
454
+ const SPREADSHEET_ID_PROPERTY = {
455
+ type: "string",
456
+ minLength: 1,
457
+ maxLength: 256,
458
+ pattern: "^[A-Za-z0-9_-]+$",
459
+ description: "Spreadsheet id: the part of its URL between /d/ and /edit. Find one by name with a Drive search; Sheets cannot list.",
460
+ };
461
+ /**
462
+ * One line, at least one character that is not a dot: a range is a path
463
+ * segment for append, and `.` or `..` there would climb out of it.
464
+ */
465
+ const RANGE_PATTERN = "^[^\\r\\n]*[^.\\r\\n][^\\r\\n]*$";
466
+ function rangeProperty(description) {
467
+ return { type: "string", minLength: 1, maxLength: 512, pattern: RANGE_PATTERN, description };
468
+ }
469
+ const A1_HELP = "A1 notation: Sheet1!A1:D20, 'Q3 Budget'!B:B, a sheet title alone, or a named range";
470
+ const RANGES_PROPERTY = {
471
+ type: "array",
472
+ minItems: 1,
473
+ maxItems: MAX_RANGES,
474
+ items: rangeProperty(`${A1_HELP}.`),
475
+ description: `Ranges to read, 1 to ${MAX_RANGES}, returned in this order.`,
476
+ };
477
+ const VALUE_INPUT_PROPERTY = {
478
+ type: "string",
479
+ enum: ["RAW", "USER_ENTERED"],
480
+ description: "RAW stores each value literally; USER_ENTERED parses it as if typed, so =SUM(A1:A3) is a formula and 2026-10-06 a date.",
481
+ };
482
+ const ROWS_PROPERTY = {
483
+ type: "array",
484
+ minItems: 1,
485
+ maxItems: MAX_WRITE_ROWS,
486
+ items: {
487
+ type: "array",
488
+ maxItems: MAX_COLUMNS,
489
+ items: { type: ["string", "number", "boolean", "null"], maxLength: 50_000 },
490
+ description: "One row's cells, left to right.",
491
+ },
492
+ description: `Rows of cells, top to bottom. null leaves a cell unchanged; "" empties it. At most ${MAX_WRITE_CELLS} cells per call, connecta's cap.`,
493
+ };
494
+ const PAGE_SCHEMA = {
495
+ type: "object",
496
+ properties: {
497
+ hasMore: { type: "boolean" },
498
+ nextCursor: { type: ["string", "null"] },
499
+ },
500
+ required: ["hasMore", "nextCursor"],
501
+ };
502
+ const CELL_SCHEMA = { type: ["string", "number", "boolean"] };
503
+ const UPDATE_SCHEMA = {
504
+ type: "object",
505
+ properties: {
506
+ updatedRange: { type: "string" },
507
+ updatedRows: { type: "integer" },
508
+ updatedColumns: { type: "integer" },
509
+ updatedCells: { type: "integer" },
510
+ },
511
+ };
512
+ const SHEET_SCHEMA = {
513
+ type: "object",
514
+ properties: {
515
+ sheetId: { type: "integer" },
516
+ title: { type: "string" },
517
+ index: { type: "integer" },
518
+ type: { type: "string" },
519
+ hidden: { type: "boolean" },
520
+ rowCount: { type: "integer" },
521
+ columnCount: { type: "integer" },
522
+ frozenRowCount: { type: "integer" },
523
+ frozenColumnCount: { type: "integer" },
524
+ },
525
+ };
526
+ function projectSheet(value) {
527
+ const properties = asRecord(asRecord(value)["properties"]);
528
+ const grid = asRecord(properties["gridProperties"]);
529
+ return compact({
530
+ // The first sheet's id is 0, which Google leaves out of the JSON.
531
+ sheetId: integer(properties["sheetId"]) ?? 0,
532
+ title: text(properties["title"]),
533
+ index: integer(properties["index"]) ?? 0,
534
+ type: text(properties["sheetType"]),
535
+ hidden: properties["hidden"] === true ? true : undefined,
536
+ rowCount: integer(grid["rowCount"]),
537
+ columnCount: integer(grid["columnCount"]),
538
+ frozenRowCount: integer(grid["frozenRowCount"]),
539
+ frozenColumnCount: integer(grid["frozenColumnCount"]),
540
+ });
541
+ }
542
+ /** The metadata get_spreadsheet reads: no grid data, no formatting. */
543
+ const SPREADSHEET_FIELDS = [
544
+ "spreadsheetId",
545
+ "spreadsheetUrl",
546
+ "properties(title,locale,timeZone)",
547
+ "sheets.properties(sheetId,title,index,sheetType,hidden,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount))",
548
+ "namedRanges(namedRangeId,name,range)",
549
+ ].join(",");
550
+ // --- Tools ------------------------------------------------------------------------
551
+ function tools(client) {
552
+ const readOnly = { readOnlyHint: true };
553
+ // Additive: nothing that existed is lost. Program writes require a trusted
554
+ // pool; other writes use call_destructive_tool. The host controls approval.
555
+ const additive = { readOnlyHint: false, destructiveHint: false };
556
+ const destructive = { readOnlyHint: false, destructiveHint: true };
557
+ const spreadsheetPath = (args) => `/spreadsheets/${encodeURIComponent(String(args["spreadsheetId"]))}`;
558
+ return [
559
+ {
560
+ name: "get_spreadsheet",
561
+ description: "Get a spreadsheet's title, sheets (ids, titles, grid sizes), and named ranges as A1, never cell values. To find one by name, search Drive; Sheets cannot list.",
562
+ annotations: readOnly,
563
+ inputSchema: input({
564
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
565
+ raw: {
566
+ type: "boolean",
567
+ description: "Return Google's untouched spreadsheet metadata — formatting, protections, charts — instead of the projection. Never cell values.",
568
+ },
569
+ }, ["spreadsheetId"]),
570
+ outputSchema: {
571
+ type: "object",
572
+ properties: {
573
+ spreadsheetId: { type: "string" },
574
+ title: { type: "string" },
575
+ url: { type: "string" },
576
+ locale: { type: "string" },
577
+ timeZone: { type: "string" },
578
+ sheets: { type: "array", items: SHEET_SCHEMA },
579
+ namedRanges: {
580
+ type: "array",
581
+ items: {
582
+ type: "object",
583
+ properties: {
584
+ namedRangeId: { type: "string" },
585
+ name: { type: "string" },
586
+ sheetId: { type: "integer" },
587
+ range: { type: "string" },
588
+ },
589
+ },
590
+ },
591
+ namedRangesOmitted: { type: "integer" },
592
+ },
593
+ required: ["spreadsheetId", "sheets"],
594
+ },
595
+ handler: async (args, ctx) => {
596
+ if (args["raw"] === true) {
597
+ return await client.json({ method: "GET", path: spreadsheetPath(args), query: { includeGridData: false } }, ctx);
598
+ }
599
+ const spreadsheet = asRecord(await client.json({ method: "GET", path: spreadsheetPath(args), query: { fields: SPREADSHEET_FIELDS } }, ctx));
600
+ const properties = asRecord(spreadsheet["properties"]);
601
+ const sheets = asArray(spreadsheet["sheets"]).map(projectSheet);
602
+ const titles = new Map(sheets.map((sheet) => [sheet["sheetId"], sheet["title"]]));
603
+ const named = asArray(spreadsheet["namedRanges"]);
604
+ return compact({
605
+ spreadsheetId: text(spreadsheet["spreadsheetId"]) ?? args["spreadsheetId"],
606
+ title: text(properties["title"]),
607
+ url: text(spreadsheet["spreadsheetUrl"]),
608
+ locale: text(properties["locale"]),
609
+ timeZone: text(properties["timeZone"]),
610
+ sheets,
611
+ namedRanges: named.length > 0
612
+ ? named.slice(0, MAX_NAMED_RANGES).map((value) => {
613
+ const entry = asRecord(value);
614
+ const range = asRecord(entry["range"]);
615
+ const sheetId = integer(range["sheetId"]) ?? 0;
616
+ return compact({
617
+ namedRangeId: text(entry["namedRangeId"]),
618
+ name: text(entry["name"]),
619
+ sheetId,
620
+ range: gridRangeA1(range, titles.get(sheetId)),
621
+ });
622
+ })
623
+ : undefined,
624
+ namedRangesOmitted: named.length > MAX_NAMED_RANGES ? named.length - MAX_NAMED_RANGES : undefined,
625
+ });
626
+ },
627
+ },
628
+ {
629
+ name: "get_values",
630
+ description: "Read cell values from one or more A1 ranges as rows, paged by cell count. Long cells are cut with a marker; no formatting, notes, or formulas unless asked.",
631
+ annotations: readOnly,
632
+ inputSchema: input({
633
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
634
+ ranges: RANGES_PROPERTY,
635
+ valueRenderOption: {
636
+ type: "string",
637
+ enum: ["FORMATTED_VALUE", "UNFORMATTED_VALUE", "FORMULA"],
638
+ description: "FORMATTED_VALUE (default) as displayed; UNFORMATTED_VALUE as raw numbers; FORMULA shows formulas instead of results.",
639
+ },
640
+ dateTimeRenderOption: {
641
+ type: "string",
642
+ enum: ["SERIAL_NUMBER", "FORMATTED_STRING"],
643
+ description: "Dates when valueRenderOption is not FORMATTED_VALUE: SERIAL_NUMBER (Google's default, days since 1899-12-30) or FORMATTED_STRING.",
644
+ },
645
+ maxCells: {
646
+ type: "integer",
647
+ minimum: 1,
648
+ maximum: MAX_PAGE_CELLS,
649
+ description: `Cells per page, 1 to ${MAX_PAGE_CELLS}; defaults to ${DEFAULT_PAGE_CELLS}. Connecta's cap: Sheets returns whole ranges, so this pages by whole rows.`,
650
+ },
651
+ maxBytes: {
652
+ type: "integer",
653
+ minimum: 1_024,
654
+ maximum: MAX_PAGE_BYTES,
655
+ description: `Bytes of the whole page as JSON; defaults to ${DEFAULT_PAGE_BYTES}, what execute_code can receive. Raise it, to ${MAX_PAGE_BYTES}, only for a direct call_tool read.`,
656
+ },
657
+ maxCellChars: {
658
+ type: "integer",
659
+ minimum: 1,
660
+ maximum: MAX_CELL_CHARS,
661
+ description: `Characters kept per cell, to ${MAX_CELL_CHARS} (Sheets' cell limit, so the whole cell); defaults to ${DEFAULT_CELL_CHARS}. Longer cells end with a marker.`,
662
+ },
663
+ cursor: {
664
+ type: "string",
665
+ minLength: 1,
666
+ maxLength: 1024,
667
+ description: "Opaque page.nextCursor from the previous page. Pass it back unchanged with the same spreadsheetId, ranges, and render options.",
668
+ },
669
+ }, ["spreadsheetId", "ranges"]),
670
+ outputSchema: {
671
+ type: "object",
672
+ properties: {
673
+ spreadsheetId: { type: "string" },
674
+ valueRanges: {
675
+ type: "array",
676
+ items: {
677
+ type: "object",
678
+ properties: {
679
+ range: { type: "string" },
680
+ values: { type: "array", items: { type: "array", items: CELL_SCHEMA } },
681
+ rowOffset: { type: "integer" },
682
+ rowCount: { type: "integer" },
683
+ truncated: { type: "boolean" },
684
+ omittedRows: { type: "integer" },
685
+ },
686
+ },
687
+ },
688
+ cellCount: { type: "integer" },
689
+ truncatedCells: { type: "integer" },
690
+ page: PAGE_SCHEMA,
691
+ },
692
+ required: ["spreadsheetId", "valueRanges", "page"],
693
+ },
694
+ handler: async (args, ctx) => {
695
+ const ranges = args["ranges"];
696
+ const budget = args["maxCells"] ?? DEFAULT_PAGE_CELLS;
697
+ const byteBudget = args["maxBytes"] ?? DEFAULT_PAGE_BYTES;
698
+ const cellChars = args["maxCellChars"] ?? DEFAULT_CELL_CHARS;
699
+ const digest = await readDigest(args);
700
+ const resume = typeof args["cursor"] === "string" ? decodeCursor(args["cursor"], ranges, digest) : { d: digest, i: 0, s: 0 };
701
+ // Every range sent is the caller's own, or the caller's own rewritten
702
+ // to start further down; nothing in the cursor names a range.
703
+ const rewritten = rangeAfter(ranges[resume.i], resume.s);
704
+ const sent = [rewritten ?? ranges[resume.i], ...ranges.slice(resume.i + 1)];
705
+ const skip = rewritten === undefined ? resume.s : 0;
706
+ const response = asRecord(await client.json({
707
+ method: "GET",
708
+ path: `${spreadsheetPath(args)}/values:batchGet`,
709
+ query: {
710
+ ranges: sent,
711
+ majorDimension: "ROWS",
712
+ valueRenderOption: args["valueRenderOption"],
713
+ dateTimeRenderOption: args["dateTimeRenderOption"],
714
+ },
715
+ }, ctx));
716
+ const spreadsheetId = text(response["spreadsheetId"]) ?? args["spreadsheetId"];
717
+ const valueRanges = [];
718
+ const cut = { cells: 0 };
719
+ let cells = 0;
720
+ // maxBytes bounds the whole page as serialized, not just its cells. The
721
+ // envelope is reserved first at its largest: both counts at their
722
+ // ceiling and the longest cursor this call could end with.
723
+ let bytes = jsonBytes({
724
+ spreadsheetId,
725
+ valueRanges: [],
726
+ cellCount: MAX_PAGE_CELLS,
727
+ truncatedCells: MAX_PAGE_CELLS,
728
+ page: {
729
+ hasMore: false,
730
+ nextCursor: encodeCursor({ d: digest, i: ranges.length - 1, s: Number.MAX_SAFE_INTEGER }),
731
+ },
732
+ });
733
+ let next;
734
+ const returned = asArray(response["valueRanges"]);
735
+ for (let index = 0; index < returned.length && next === undefined; index += 1) {
736
+ const valueRange = asRecord(returned[index]);
737
+ // Rows of the caller's range earlier pages returned, and of those,
738
+ // how many Google sent again and this page drops.
739
+ const before = index === 0 ? resume.s : 0;
740
+ const dropped = index === 0 ? skip : 0;
741
+ const rows = asArray(valueRange["values"]).slice(dropped);
742
+ const echoed = text(valueRange["range"]) ?? sent[index];
743
+ const entry = (kept, truncated) => compact({
744
+ range: echoed,
745
+ rowOffset: dropped > 0 ? dropped : undefined,
746
+ values: kept,
747
+ rowCount: kept.length,
748
+ truncated: truncated ? true : undefined,
749
+ omittedRows: truncated ? rows.length - kept.length : undefined,
750
+ });
751
+ // This range's own fields at their largest, and the comma before it;
752
+ // an empty range costs them too.
753
+ const fields = jsonBytes(compact({
754
+ range: echoed,
755
+ rowOffset: dropped > 0 ? dropped : undefined,
756
+ values: [],
757
+ rowCount: rows.length,
758
+ truncated: true,
759
+ omittedRows: rows.length,
760
+ })) + 1;
761
+ if (bytes + fields > byteBudget) {
762
+ if (valueRanges.length === 0)
763
+ throw pageTooSmall(echoed, bytes + fields, byteBudget);
764
+ next = { d: digest, i: resume.i + index, s: before };
765
+ break;
766
+ }
767
+ bytes += fields;
768
+ const kept = [];
769
+ for (const row of rows) {
770
+ const empty = valueRanges.length === 0 && kept.length === 0;
771
+ // An empty row still costs one cell, so every page makes progress.
772
+ const width = Math.max(1, asArray(row).length);
773
+ if (cells + width > budget) {
774
+ if (cells === 0)
775
+ throw rowTooWide(echoed, dropped + kept.length + 1, width, budget);
776
+ break;
777
+ }
778
+ const rowCut = { cells: 0 };
779
+ const capped = asArray(row).map((cell) => cappedCell(cell, cellChars, rowCut));
780
+ // The row as serialized, plus the comma before it.
781
+ const size = jsonBytes(capped) + 1;
782
+ if (bytes + size > byteBudget) {
783
+ if (empty)
784
+ throw rowTooLarge(echoed, dropped + 1, bytes + size, byteBudget);
785
+ break;
786
+ }
787
+ cells += width;
788
+ bytes += size;
789
+ cut.cells += rowCut.cells;
790
+ kept.push(capped);
791
+ }
792
+ if (kept.length < rows.length) {
793
+ next = { d: digest, i: resume.i + index, s: before + kept.length };
794
+ // A range this page never reached is not reported half-read; the
795
+ // next page starts it.
796
+ if (kept.length > 0)
797
+ valueRanges.push(entry(kept, true));
798
+ break;
799
+ }
800
+ valueRanges.push(entry(kept, false));
801
+ if (cells >= budget && index + 1 < returned.length) {
802
+ next = { d: digest, i: resume.i + index + 1, s: 0 };
803
+ }
804
+ }
805
+ const nextCursor = next ? encodeCursor(next) : null;
806
+ return compact({
807
+ spreadsheetId,
808
+ valueRanges,
809
+ cellCount: cells,
810
+ truncatedCells: cut.cells > 0 ? cut.cells : undefined,
811
+ page: { hasMore: nextCursor !== null, nextCursor },
812
+ });
813
+ },
814
+ },
815
+ {
816
+ name: "update_values",
817
+ description: "Write rows of values into one A1 range, overwriting what is there. Cells past the given rows are untouched; to add rows below a table use append_values.",
818
+ annotations: destructive,
819
+ inputSchema: input({
820
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
821
+ range: rangeProperty(`${A1_HELP}; the top-left cell is where the first value goes.`),
822
+ values: ROWS_PROPERTY,
823
+ valueInputOption: VALUE_INPUT_PROPERTY,
824
+ }, ["spreadsheetId", "range", "values", "valueInputOption"]),
825
+ outputSchema: {
826
+ type: "object",
827
+ properties: { spreadsheetId: { type: "string" }, ...UPDATE_SCHEMA.properties },
828
+ required: ["spreadsheetId"],
829
+ },
830
+ handler: async (args, ctx) => {
831
+ checkWriteSize([args["values"]]);
832
+ const response = asRecord(await client.json({
833
+ method: "POST",
834
+ path: `${spreadsheetPath(args)}/values:batchUpdate`,
835
+ body: {
836
+ valueInputOption: args["valueInputOption"],
837
+ data: [{ range: args["range"], majorDimension: "ROWS", values: args["values"] }],
838
+ includeValuesInResponse: false,
839
+ },
840
+ }, ctx,
841
+ // The same cells set to the same values twice is one write: a 5xx
842
+ // stays retryable.
843
+ { idempotent: true }));
844
+ return {
845
+ spreadsheetId: text(response["spreadsheetId"]) ?? args["spreadsheetId"],
846
+ ...projectUpdate(asArray(response["responses"])[0]),
847
+ };
848
+ },
849
+ },
850
+ {
851
+ name: "batch_update_values",
852
+ description: "Write values into several A1 ranges in one call, overwriting each. All succeed or none do. For formatting or structure use batch_update_spreadsheet.",
853
+ annotations: destructive,
854
+ inputSchema: input({
855
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
856
+ data: {
857
+ type: "array",
858
+ minItems: 1,
859
+ maxItems: MAX_DATA_RANGES,
860
+ items: {
861
+ type: "object",
862
+ properties: {
863
+ range: rangeProperty(`${A1_HELP}.`),
864
+ values: ROWS_PROPERTY,
865
+ },
866
+ required: ["range", "values"],
867
+ additionalProperties: false,
868
+ },
869
+ description: `Ranges and their rows, 1 to ${MAX_DATA_RANGES}.`,
870
+ },
871
+ valueInputOption: VALUE_INPUT_PROPERTY,
872
+ }, ["spreadsheetId", "data", "valueInputOption"]),
873
+ outputSchema: {
874
+ type: "object",
875
+ properties: {
876
+ spreadsheetId: { type: "string" },
877
+ totalUpdatedRows: { type: "integer" },
878
+ totalUpdatedColumns: { type: "integer" },
879
+ totalUpdatedCells: { type: "integer" },
880
+ totalUpdatedSheets: { type: "integer" },
881
+ updates: { type: "array", items: UPDATE_SCHEMA },
882
+ },
883
+ required: ["spreadsheetId", "updates"],
884
+ },
885
+ handler: async (args, ctx) => {
886
+ const data = asArray(args["data"]).map(asRecord);
887
+ checkWriteSize(data.map((entry) => entry["values"]));
888
+ const response = asRecord(await client.json({
889
+ method: "POST",
890
+ path: `${spreadsheetPath(args)}/values:batchUpdate`,
891
+ body: {
892
+ valueInputOption: args["valueInputOption"],
893
+ data: data.map((entry) => ({ range: entry["range"], majorDimension: "ROWS", values: entry["values"] })),
894
+ includeValuesInResponse: false,
895
+ },
896
+ }, ctx, { idempotent: true }));
897
+ return {
898
+ spreadsheetId: text(response["spreadsheetId"]) ?? args["spreadsheetId"],
899
+ totalUpdatedRows: integer(response["totalUpdatedRows"]) ?? 0,
900
+ totalUpdatedColumns: integer(response["totalUpdatedColumns"]) ?? 0,
901
+ totalUpdatedCells: integer(response["totalUpdatedCells"]) ?? 0,
902
+ totalUpdatedSheets: integer(response["totalUpdatedSheets"]) ?? 0,
903
+ updates: asArray(response["responses"]).map(projectUpdate),
904
+ };
905
+ },
906
+ },
907
+ {
908
+ name: "append_values",
909
+ description: "Append rows after the last row of the table in an A1 range, inserting new rows so nothing below is overwritten. Returns where they landed.",
910
+ annotations: additive,
911
+ inputSchema: input({
912
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
913
+ range: rangeProperty(`${A1_HELP}; Sheets finds the table in it and appends below its last row.`),
914
+ values: ROWS_PROPERTY,
915
+ valueInputOption: VALUE_INPUT_PROPERTY,
916
+ }, ["spreadsheetId", "range", "values", "valueInputOption"]),
917
+ outputSchema: {
918
+ type: "object",
919
+ properties: {
920
+ spreadsheetId: { type: "string" },
921
+ tableRange: { type: "string" },
922
+ ...UPDATE_SCHEMA.properties,
923
+ },
924
+ required: ["spreadsheetId"],
925
+ },
926
+ handler: async (args, ctx) => {
927
+ checkWriteSize([args["values"]]);
928
+ const response = asRecord(
929
+ // Appending is not idempotent: a second call after one that landed
930
+ // adds the rows twice.
931
+ await uncertainWrite("Appending again would add the rows a second time if the first append landed: read the table with get_values and append only what is missing.", () => client.json({
932
+ method: "POST",
933
+ path: `${spreadsheetPath(args)}/values/${encodeURIComponent(String(args["range"]))}:append`,
934
+ query: {
935
+ valueInputOption: args["valueInputOption"],
936
+ // Pinned: OVERWRITE writes over whatever sits below the table.
937
+ insertDataOption: "INSERT_ROWS",
938
+ includeValuesInResponse: false,
939
+ },
940
+ body: { majorDimension: "ROWS", values: args["values"] },
941
+ }, ctx)));
942
+ return compact({
943
+ spreadsheetId: text(response["spreadsheetId"]) ?? args["spreadsheetId"],
944
+ tableRange: text(response["tableRange"]),
945
+ ...projectUpdate(response["updates"]),
946
+ });
947
+ },
948
+ },
949
+ {
950
+ name: "clear_values",
951
+ description: "Clear the values in one or more A1 ranges, keeping formatting, notes, and validation. The values are gone; nothing is deleted or shifted.",
952
+ annotations: destructive,
953
+ inputSchema: input({
954
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
955
+ ranges: { ...RANGES_PROPERTY, description: `Ranges to clear, 1 to ${MAX_RANGES}.` },
956
+ }, ["spreadsheetId", "ranges"]),
957
+ outputSchema: {
958
+ type: "object",
959
+ properties: {
960
+ spreadsheetId: { type: "string" },
961
+ clearedRanges: { type: "array", items: { type: "string" } },
962
+ },
963
+ required: ["spreadsheetId", "clearedRanges"],
964
+ },
965
+ handler: async (args, ctx) => {
966
+ const response = asRecord(await client.json({
967
+ method: "POST",
968
+ path: `${spreadsheetPath(args)}/values:batchClear`,
969
+ body: { ranges: args["ranges"] },
970
+ }, ctx,
971
+ // Clearing fixed ranges twice clears them once.
972
+ { idempotent: true }));
973
+ return {
974
+ spreadsheetId: text(response["spreadsheetId"]) ?? args["spreadsheetId"],
975
+ clearedRanges: asArray(response["clearedRanges"]).filter((range) => typeof range === "string"),
976
+ };
977
+ },
978
+ },
979
+ {
980
+ name: "create_spreadsheet",
981
+ description: "Create a new spreadsheet in the user's My Drive root, optionally with named sheets. It is not shared or moved; that is Drive's job.",
982
+ annotations: additive,
983
+ inputSchema: input({
984
+ title: {
985
+ type: "string",
986
+ minLength: 1,
987
+ maxLength: 255,
988
+ pattern: "^[^\\r\\n]*$",
989
+ description: "The spreadsheet's file name.",
990
+ },
991
+ sheetTitles: {
992
+ type: "array",
993
+ minItems: 1,
994
+ maxItems: 50,
995
+ uniqueItems: true,
996
+ items: {
997
+ type: "string",
998
+ minLength: 1,
999
+ maxLength: 100,
1000
+ pattern: "^[^\\r\\n]*$",
1001
+ description: "One sheet tab's title.",
1002
+ },
1003
+ description: "Sheet tabs to start with, in order; omit for Google's one sheet, Sheet1.",
1004
+ },
1005
+ }, ["title"]),
1006
+ outputSchema: {
1007
+ type: "object",
1008
+ properties: {
1009
+ spreadsheetId: { type: "string" },
1010
+ title: { type: "string" },
1011
+ url: { type: "string" },
1012
+ sheets: {
1013
+ type: "array",
1014
+ items: { type: "object", properties: { sheetId: { type: "integer" }, title: { type: "string" } } },
1015
+ },
1016
+ },
1017
+ required: ["spreadsheetId", "sheets"],
1018
+ },
1019
+ handler: async (args, ctx) => {
1020
+ const titles = args["sheetTitles"];
1021
+ const spreadsheet = asRecord(
1022
+ // A spreadsheet that may exist has no id to re-read; only its title
1023
+ // can find it, and Sheets cannot search.
1024
+ await uncertainWrite("Search Drive for this title before creating it again, or a second spreadsheet results.", () => client.json({
1025
+ method: "POST",
1026
+ path: "/spreadsheets",
1027
+ query: { fields: "spreadsheetId,spreadsheetUrl,properties.title,sheets.properties(sheetId,title)" },
1028
+ body: compact({
1029
+ properties: { title: args["title"] },
1030
+ sheets: titles?.map((title) => ({ properties: { title } })),
1031
+ }),
1032
+ }, ctx)));
1033
+ return compact({
1034
+ spreadsheetId: text(spreadsheet["spreadsheetId"]),
1035
+ title: text(asRecord(spreadsheet["properties"])["title"]),
1036
+ url: text(spreadsheet["spreadsheetUrl"]),
1037
+ sheets: asArray(spreadsheet["sheets"]).map((sheet) => {
1038
+ const { sheetId, title } = projectSheet(sheet);
1039
+ return compact({ sheetId, title });
1040
+ }),
1041
+ });
1042
+ },
1043
+ },
1044
+ {
1045
+ name: "add_sheet",
1046
+ description: "Add a new, empty sheet tab to a spreadsheet. Fails if the title is taken; it never renames, replaces, or copies an existing sheet.",
1047
+ annotations: additive,
1048
+ inputSchema: input({
1049
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
1050
+ title: {
1051
+ type: "string",
1052
+ minLength: 1,
1053
+ maxLength: 100,
1054
+ pattern: "^[^\\r\\n]*$",
1055
+ description: "The new tab's title, unique in the spreadsheet.",
1056
+ },
1057
+ index: {
1058
+ type: "integer",
1059
+ minimum: 0,
1060
+ maximum: 1_000,
1061
+ description: "Tab position, 0 first; omit to add it last.",
1062
+ },
1063
+ rowCount: {
1064
+ type: "integer",
1065
+ minimum: 1,
1066
+ maximum: 1_000_000,
1067
+ description: "Rows in the grid; Google defaults to 1,000.",
1068
+ },
1069
+ columnCount: {
1070
+ type: "integer",
1071
+ minimum: 1,
1072
+ maximum: MAX_COLUMNS,
1073
+ description: "Columns in the grid; Google defaults to 26.",
1074
+ },
1075
+ }, ["spreadsheetId", "title"]),
1076
+ outputSchema: {
1077
+ type: "object",
1078
+ properties: { spreadsheetId: { type: "string" }, ...SHEET_SCHEMA.properties },
1079
+ required: ["spreadsheetId", "sheetId"],
1080
+ },
1081
+ handler: async (args, ctx) => {
1082
+ const grid = compact({ rowCount: args["rowCount"], columnCount: args["columnCount"] });
1083
+ const response = asRecord(await client.json({
1084
+ method: "POST",
1085
+ path: `${spreadsheetPath(args)}:batchUpdate`,
1086
+ body: {
1087
+ requests: [
1088
+ {
1089
+ addSheet: {
1090
+ properties: compact({
1091
+ title: args["title"],
1092
+ index: args["index"],
1093
+ gridProperties: Object.keys(grid).length > 0 ? grid : undefined,
1094
+ }),
1095
+ },
1096
+ },
1097
+ ],
1098
+ },
1099
+ }, ctx));
1100
+ const added = asRecord(asRecord(asArray(response["replies"])[0])["addSheet"]);
1101
+ return {
1102
+ spreadsheetId: text(response["spreadsheetId"]) ?? args["spreadsheetId"],
1103
+ ...projectSheet(added),
1104
+ };
1105
+ },
1106
+ },
1107
+ {
1108
+ name: "batch_update_spreadsheet",
1109
+ description: "Apply raw Sheets batchUpdate requests — formatting, merges, sorts, deleting sheets or rows, protections — atomically. The approval-gated hatch; it can destroy data.",
1110
+ // Destructive, whatever the requests are: they can delete sheets, rows,
1111
+ // ranges, and protections, and connecta does not read them to guess.
1112
+ annotations: destructive,
1113
+ inputSchema: input({
1114
+ spreadsheetId: SPREADSHEET_ID_PROPERTY,
1115
+ requests: {
1116
+ type: "array",
1117
+ minItems: 1,
1118
+ maxItems: MAX_BATCH_REQUESTS,
1119
+ items: {
1120
+ type: "object",
1121
+ minProperties: 1,
1122
+ maxProperties: 1,
1123
+ description: "One Sheets Request with exactly one kind set, as Google documents it: { repeatCell: {…} }, { deleteDimension: {…} }, { sortRange: {…} }.",
1124
+ },
1125
+ description: `Sheets API Request objects, 1 to ${MAX_BATCH_REQUESTS}, applied in order; all succeed or none do.`,
1126
+ },
1127
+ }, ["spreadsheetId", "requests"]),
1128
+ outputSchema: {
1129
+ type: "object",
1130
+ properties: {
1131
+ spreadsheetId: { type: "string" },
1132
+ replies: { type: "array", items: { type: "object" } },
1133
+ repliesSummarized: { type: "integer" },
1134
+ repliesOmitted: { type: "integer" },
1135
+ note: { type: "string" },
1136
+ },
1137
+ required: ["spreadsheetId", "replies"],
1138
+ },
1139
+ handler: async (args, ctx) => {
1140
+ checkRequestKinds(args["requests"]);
1141
+ const response = asRecord(await client.json({
1142
+ method: "POST",
1143
+ path: `${spreadsheetPath(args)}:batchUpdate`,
1144
+ body: { requests: args["requests"], includeSpreadsheetInResponse: false },
1145
+ }, ctx));
1146
+ return deliverableReplies(text(response["spreadsheetId"]) ?? args["spreadsheetId"], asArray(response["replies"]).map(asRecord), asArray(args["requests"]).length);
1147
+ },
1148
+ },
1149
+ ];
1150
+ }
1151
+ // --- Guide ------------------------------------------------------------------------
1152
+ function usageGuide(purpose, instructions) {
1153
+ const extra = instructions?.trim();
1154
+ return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${DEFAULT_CELL_CHARS}${skill.fragments.guide_2}${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
1155
+ }
1156
+ // --- Construction -----------------------------------------------------------------
1157
+ /**
1158
+ * A maintained Google Sheets connection acting as each signed-in Workspace
1159
+ * user through a delegated service account.
1160
+ *
1161
+ * Setup, once per Workspace (shared with every Workspace provider):
1162
+ *
1163
+ * 1. In a Google Cloud project, enable the **Google Sheets API**.
1164
+ * 2. Create a service account. Grant it no IAM roles: delegation, not project
1165
+ * permission, is what reaches spreadsheets.
1166
+ * 3. Create a JSON key for it. If key creation is refused, the organization
1167
+ * policy `iam.disableServiceAccountKeyCreation` is enforced; an org policy
1168
+ * administrator can override it for this project alone.
1169
+ * 4. Copy the service account's numeric **client ID** (the key's `client_id`).
1170
+ * 5. As a Workspace super admin: Admin console → Security → Access and data
1171
+ * control → API controls → Manage Domain Wide Delegation → Add new (or
1172
+ * edit the client ID's existing entry — one client ID has one entry, so add
1173
+ * to the scopes another Workspace provider already listed rather than
1174
+ * replacing them). The scope this connection needs ({@link SHEETS_SCOPES}):
1175
+ * `https://www.googleapis.com/auth/spreadsheets`.
1176
+ * A new grant can take up to 24 hours to apply.
1177
+ * 6. Configure inbound auth: a `subject` function is never asked about an
1178
+ * open deployment's anonymous requests, which fail `auth_required`.
1179
+ * Then give the deployment the key as a secret and map callers to accounts:
1180
+ *
1181
+ * ```ts
1182
+ * sheets("sheets", {
1183
+ * purpose: "Finance and attendance spreadsheets",
1184
+ * serviceAccount: env.GOOGLE_SERVICE_ACCOUNT_JSON,
1185
+ * subject: (identity) => accounts[identity.principal?.id ?? ""],
1186
+ * });
1187
+ * ```
1188
+ *
1189
+ * Reads are read-only. `create_spreadsheet`, `add_sheet`, and `append_values`
1190
+ * are additive writes the host approves through call_destructive_tool or a
1191
+ * trusted program; the value writes, `clear_values`, and the raw
1192
+ * `batch_update_spreadsheet` are destructive.
1193
+ */
1194
+ export const sheets = asProviderFactory({
1195
+ name: "sheets",
1196
+ title: "Google Sheets",
1197
+ kind: "api",
1198
+ readme: "Google Sheets",
1199
+ bundle: {
1200
+ "baselineGzip": 26026,
1201
+ "maxGzip": 86026,
1202
+ "note": "./providers/sheets starts at 26,026 B gzip (#682): 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.",
1203
+ },
1204
+ skill,
1205
+ options: GOOGLE_WORKSPACE_OPTIONS,
1206
+ create: sheetsConnector,
1207
+ });
1208
+ function sheetsConnector(id, options) {
1209
+ const connection = workspaceConnection("sheets", options);
1210
+ const client = googleWorkspaceClient({
1211
+ provider: "Google Sheets",
1212
+ api: "Google Sheets API",
1213
+ baseUrl: options.baseUrl?.trim() || SHEETS_API_BASE_URL,
1214
+ scopes: SHEETS_SCOPES,
1215
+ maxResponseBytes: SHEETS_MAX_RESPONSE_BYTES,
1216
+ // A spreadsheet is a Drive file: a 404 may be a file that exists and is
1217
+ // not shared with this user, so absence is never asserted.
1218
+ notFound: "ambiguous",
1219
+ connection,
1220
+ });
1221
+ return api(id, {
1222
+ ...defined({
1223
+ callAdmission: options.callAdmission,
1224
+ maxResultBytes: options.maxResultBytes,
1225
+ }),
1226
+ title: options.title ?? "Google Sheets",
1227
+ description: `Google Sheets as the signed-in Workspace user: read and write values in spreadsheets they can open, by id — ${connection.purpose}`,
1228
+ usageGuide: {
1229
+ content: usageGuide(connection.purpose, options.instructions),
1230
+ summary: "Each caller's own spreadsheets by id: A1 ranges, cell-paged reads, RAW or USER_ENTERED writes, raw batchUpdate.",
1231
+ // Required: whose spreadsheets they are, that Drive finds them, and what
1232
+ // null and "" mean in a written row are conventions no schema carries.
1233
+ required: true,
1234
+ },
1235
+ tools: tools(client),
1236
+ });
1237
+ }