@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,1523 @@
1
+ import { skill } from "./skill.generated.js";
2
+ /**
3
+ * Google Drive, as the signed-in Workspace user: search, read, create,
4
+ * upload, rename, move, copy, trash and restore files, and read and change who
5
+ * they are shared with — and nothing that deletes a file for good or hands one
6
+ * to a new owner. Hand-written against the Drive API v3 reference
7
+ * (https://developers.google.com/workspace/drive/api/reference/rest/v3).
8
+ *
9
+ * Whose Drive. 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. Drive has no `users/me` segment to confine beneath; the token's
13
+ * subject is what every request acts as, so a file id reaches exactly what
14
+ * that person could open in the Drive web app, shared drives included, and no
15
+ * argument names anyone else.
16
+ *
17
+ * Two transports, one account. Uploads go to Drive's upload host
18
+ * (`/upload/drive/v3`), outside the API root, so the provider builds a second
19
+ * confined client for it rather than widening the first to all of
20
+ * `www.googleapis.com`. Each tool uses one of the two, never both, so a
21
+ * `subject` function still runs once per call.
22
+ *
23
+ * What it will not do. There is no permanent delete, no empty-trash, and no
24
+ * ownership transfer: trash is recoverable for thirty days, and an owner change
25
+ * is not. Every write that overwrites content, renames, moves, trashes, or
26
+ * changes sharing is destructive and crosses the host's approval prompt.
27
+ *
28
+ * Drift. `src/providers/drive/drift.json` records the eleven methods the
29
+ * tools call, and `npm run providers:check -- --provider drive` reports a
30
+ * touched contract that moved or a method that stopped accepting the scope
31
+ * below.
32
+ */
33
+ import { apiConnector as api, defined } from "../../connectors/api-connector.js";
34
+ import { ConnectorCallError } from "../../errors.js";
35
+ import { RESULT_BUDGET_BYTES, clampText, jsonBytes } from "../_shared/google/result-size.js";
36
+ import { googleOutcomeOf, GOOGLE_WORKSPACE_OPTIONS, googleWorkspaceClient, workspaceConnection, } from "../_shared/google/workspace.js";
37
+ import { asProviderFactory } from "../../provider.js";
38
+ /**
39
+ * Drive's API root. Uploads use the same path beneath `/upload` on the same
40
+ * origin, which the provider derives from this (or from a `baseUrl` override).
41
+ */
42
+ export const DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3";
43
+ /**
44
+ * Exactly the scope this connection requests, and exactly what the Admin
45
+ * console's domain-wide delegation entry must list. Full `drive`, because
46
+ * `drive.file` reaches only files the app itself created and every narrower
47
+ * scope is read-only.
48
+ */
49
+ export const DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive"];
50
+ /** Rows are one cheap request per page; Drive's own maximum is 1,000. */
51
+ const MAX_PAGE_SIZE = 100;
52
+ const DEFAULT_PAGE_SIZE = 25;
53
+ const DEFAULT_CONTENT_CHARS = 20_000;
54
+ const MAX_CONTENT_CHARS = 100_000;
55
+ /** A binary file is returned as base64 at or under this size, never above. */
56
+ const BINARY_INLINE_BYTES = 1024 * 1024;
57
+ /**
58
+ * Serialized bytes of a whole read result — rows or content, envelope and
59
+ * cursor included. Every read is built to stay under `maxBytes`, measured as
60
+ * the UTF-8 length of its JSON, the way both delivery routes measure it. The
61
+ * default is the shared Workspace budget, three quarters of the 256 KiB a
62
+ * host result may carry into execute_code, so a program always receives a
63
+ * default result. A direct call_tool caller may ask for up to 4 MiB, which the
64
+ * result stash (8 MiB by default) still pages through connecta.result; execute_code
65
+ * cannot receive that much.
66
+ */
67
+ const DEFAULT_RESULT_BYTES = RESULT_BUDGET_BYTES;
68
+ /**
69
+ * Room for the costliest row — every bounded string at its bound and both
70
+ * capped lists full — beside the longest cursor this connection issues.
71
+ */
72
+ const MIN_RESULT_BYTES = 64 * 1024;
73
+ const MAX_RESULT_BYTES = 4 * 1024 * 1024;
74
+ /**
75
+ * Strings Drive does not bound for us, cut in JSON bytes so one row or one
76
+ * file always fits under the smallest `maxBytes`: a name in a listing, a
77
+ * write result, or a content result; a name and description in get_file; and
78
+ * labels such as a display name. A cut string ends with "…" and its field is
79
+ * named in the result's `truncatedFields`, so a real ellipsis is never taken
80
+ * for a cut. Identifiers are never cut: one that is not a well-formed Drive id
81
+ * is left out and named there instead.
82
+ */
83
+ const ROW_NAME_BYTES = 2 * 1024;
84
+ const DETAIL_NAME_BYTES = 32 * 1024;
85
+ const DETAIL_DESCRIPTION_BYTES = 64 * 1024;
86
+ const LABEL_BYTES = 1024;
87
+ /** Owners and parents listed per file; Drive returns one of each today. */
88
+ const MAX_LISTED_IDS = 10;
89
+ /** What a Drive id looks like; anything else is malformed, never echoed. */
90
+ const DRIVE_ID = /^[A-Za-z0-9_-]{1,256}$/;
91
+ /**
92
+ * The longest cursor this connection issues, and the input schema accepts.
93
+ * Drive's page tokens are a few hundred characters; one so long that a cursor
94
+ * carrying it would pass this is refused when Drive sends it, rather than
95
+ * issued and then refused on replay.
96
+ */
97
+ const MAX_CURSOR_CHARS = 16_384;
98
+ /** Upload ceiling for text, in characters; Drive's multipart limit is 5 MB. */
99
+ const MAX_UPLOAD_CHARS = 1_000_000;
100
+ /** Base64 characters for {@link BINARY_INLINE_BYTES} decoded bytes. */
101
+ const MAX_UPLOAD_BASE64 = 4 * Math.ceil(BINARY_INLINE_BYTES / 3);
102
+ /** Drive's export limit is 10 MB, so an export never nears this. */
103
+ const DRIVE_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
104
+ const FOLDER = "application/vnd.google-apps.folder";
105
+ const SHORTCUT = "application/vnd.google-apps.shortcut";
106
+ const NATIVE = "application/vnd.google-apps.";
107
+ /** Google file types the content tool exports, and as what. */
108
+ const EXPORTS = {
109
+ "application/vnd.google-apps.document": { mimeType: "text/markdown", format: "markdown" },
110
+ "application/vnd.google-apps.spreadsheet": {
111
+ mimeType: "text/csv",
112
+ format: "csv",
113
+ note: "CSV of the first sheet only; Drive exports no other tab as CSV.",
114
+ },
115
+ "application/vnd.google-apps.presentation": { mimeType: "text/plain", format: "text" },
116
+ };
117
+ /** `convertTo` values, as the Google type a created file becomes. */
118
+ const CONVERSIONS = {
119
+ document: "application/vnd.google-apps.document",
120
+ spreadsheet: "application/vnd.google-apps.spreadsheet",
121
+ presentation: "application/vnd.google-apps.presentation",
122
+ };
123
+ const SUMMARY_FIELDS = "id,name,mimeType,parents,driveId,size,modifiedTime,webViewLink,trashed,owners(emailAddress),shortcutDetails(targetId,targetMimeType)";
124
+ const DETAIL_FIELDS = `${SUMMARY_FIELDS},description,createdTime,lastModifyingUser(emailAddress,displayName),shared,starred,capabilities(canEdit,canComment,canShare,canDownload,canTrash,canRename,canAddChildren)`;
125
+ const PERMISSION_FIELDS = "id,type,role,emailAddress,domain,displayName,allowFileDiscovery,expirationTime,deleted,permissionDetails(inherited,inheritedFrom)";
126
+ /** Every file request reaches shared drives as well as My Drive. */
127
+ const ALL_DRIVES = { supportsAllDrives: true };
128
+ function asRecord(value) {
129
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
130
+ }
131
+ function asArray(value) {
132
+ return Array.isArray(value) ? value : [];
133
+ }
134
+ function compact(value) {
135
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
136
+ }
137
+ function text(value) {
138
+ return typeof value === "string" && value !== "" ? value : undefined;
139
+ }
140
+ function bool(value) {
141
+ return typeof value === "boolean" ? value : undefined;
142
+ }
143
+ /** Drive reports sizes as int64 strings. */
144
+ function sizeOf(value) {
145
+ const size = typeof value === "string" ? Number(value) : value;
146
+ return typeof size === "number" && Number.isSafeInteger(size) && size >= 0 ? size : undefined;
147
+ }
148
+ /** Well-formed Drive ids in a list, for a decision rather than a result. */
149
+ function idsOf(value) {
150
+ return asArray(value).filter((entry) => typeof entry === "string" && DRIVE_ID.test(entry));
151
+ }
152
+ /**
153
+ * What one projection kept, and the name of every field it shortened or left
154
+ * out — reported as `truncatedFields`, never silently.
155
+ */
156
+ class Cuts {
157
+ cut = new Set();
158
+ /** A string Drive does not bound, cut to `bytes` of JSON with "…". */
159
+ label(field, value, bytes = LABEL_BYTES) {
160
+ const whole = typeof value === "string" ? value : undefined;
161
+ if (whole === undefined || (whole === "" && field !== "name"))
162
+ return undefined;
163
+ const kept = clampText(whole, bytes, () => "…");
164
+ if (kept !== whole)
165
+ this.cut.add(field);
166
+ return kept;
167
+ }
168
+ /** An identifier whole, or left out and named when it is malformed. */
169
+ id(field, value) {
170
+ const whole = text(value);
171
+ if (whole === undefined)
172
+ return undefined;
173
+ if (DRIVE_ID.test(whole))
174
+ return whole;
175
+ this.cut.add(field);
176
+ return undefined;
177
+ }
178
+ /** Up to ten well-formed ids; the rest, and any malformed one, are named. */
179
+ ids(field, value) {
180
+ const all = asArray(value).filter((entry) => typeof entry === "string");
181
+ const kept = idsOf(all);
182
+ if (kept.length !== all.length || kept.length > MAX_LISTED_IDS)
183
+ this.cut.add(field);
184
+ return kept.length > 0 ? kept.slice(0, MAX_LISTED_IDS) : undefined;
185
+ }
186
+ /** Up to ten labels, each cut as {@link label} cuts one. */
187
+ labels(field, values) {
188
+ const present = values.filter((value) => typeof value === "string" && value !== "");
189
+ if (present.length > MAX_LISTED_IDS)
190
+ this.cut.add(field);
191
+ const kept = present.slice(0, MAX_LISTED_IDS).map((value) => this.label(field, value));
192
+ return kept.length > 0 ? kept : undefined;
193
+ }
194
+ get fields() {
195
+ return this.cut.size > 0 ? [...this.cut] : undefined;
196
+ }
197
+ }
198
+ // --- Projections ------------------------------------------------------------------
199
+ /**
200
+ * A file as an agent reads it. `fallbackId` is the id the call already named,
201
+ * for a response that omits it; `nameBytes` bounds the name. Fields cut or
202
+ * left out are named in `truncatedFields`, which `extra` may add to.
203
+ */
204
+ function projectFile(value, fallbackId, nameBytes = ROW_NAME_BYTES, extra) {
205
+ const file = asRecord(value);
206
+ const shortcut = asRecord(file["shortcutDetails"]);
207
+ const cuts = new Cuts();
208
+ const projected = compact({
209
+ id: cuts.id("id", file["id"]) ?? fallbackId,
210
+ name: cuts.label("name", file["name"], nameBytes),
211
+ mimeType: cuts.label("mimeType", file["mimeType"]),
212
+ parents: cuts.ids("parents", file["parents"]),
213
+ driveId: cuts.id("driveId", file["driveId"]),
214
+ size: sizeOf(file["size"]),
215
+ modifiedTime: cuts.label("modifiedTime", file["modifiedTime"]),
216
+ owners: cuts.labels("owners", asArray(file["owners"]).map((owner) => asRecord(owner)["emailAddress"])),
217
+ trashed: bool(file["trashed"]),
218
+ webViewLink: cuts.label("webViewLink", file["webViewLink"]),
219
+ shortcutTargetId: cuts.id("shortcutTargetId", shortcut["targetId"]),
220
+ shortcutTargetMimeType: cuts.label("shortcutTargetMimeType", shortcut["targetMimeType"]),
221
+ ...extra?.(file, cuts),
222
+ });
223
+ return compact({ ...projected, truncatedFields: cuts.fields });
224
+ }
225
+ const CAPABILITIES = [
226
+ "canEdit",
227
+ "canComment",
228
+ "canShare",
229
+ "canDownload",
230
+ "canTrash",
231
+ "canRename",
232
+ "canAddChildren",
233
+ ];
234
+ function projectFileDetail(value, fallbackId) {
235
+ return projectFile(value, fallbackId, DETAIL_NAME_BYTES, (file, cuts) => {
236
+ const modifier = asRecord(file["lastModifyingUser"]);
237
+ const granted = asRecord(file["capabilities"]);
238
+ const capabilities = compact(Object.fromEntries(CAPABILITIES.map((name) => [name, bool(granted[name])])));
239
+ return {
240
+ description: cuts.label("description", file["description"], DETAIL_DESCRIPTION_BYTES),
241
+ createdTime: cuts.label("createdTime", file["createdTime"]),
242
+ modifiedBy: cuts.label("modifiedBy", text(modifier["emailAddress"])) ??
243
+ cuts.label("modifiedBy", text(modifier["displayName"])),
244
+ shared: bool(file["shared"]),
245
+ starred: bool(file["starred"]),
246
+ capabilities: Object.keys(capabilities).length > 0 ? capabilities : undefined,
247
+ };
248
+ });
249
+ }
250
+ function projectPermission(value, fallbackId) {
251
+ const permission = asRecord(value);
252
+ const details = asArray(permission["permissionDetails"]).map(asRecord);
253
+ const cuts = new Cuts();
254
+ const inheritedFrom = details
255
+ .map((detail) => cuts.id("inheritedFrom", detail["inheritedFrom"]))
256
+ .find((id) => id !== undefined);
257
+ return compact({
258
+ id: cuts.id("id", permission["id"]) ?? fallbackId,
259
+ type: cuts.label("type", permission["type"]),
260
+ role: cuts.label("role", permission["role"]),
261
+ emailAddress: cuts.label("emailAddress", permission["emailAddress"]),
262
+ domain: cuts.label("domain", permission["domain"]),
263
+ displayName: cuts.label("displayName", permission["displayName"]),
264
+ allowFileDiscovery: bool(permission["allowFileDiscovery"]),
265
+ expirationTime: cuts.label("expirationTime", permission["expirationTime"]),
266
+ // On a shared drive, a role granted by the drive or a parent folder.
267
+ inherited: details.length > 0 ? details.some((detail) => detail["inherited"] === true) : undefined,
268
+ // The folder or shared drive to change instead, since an inherited share
269
+ // cannot be changed or removed here.
270
+ inheritedFrom,
271
+ deleted: permission["deleted"] === true ? true : undefined,
272
+ truncatedFields: cuts.fields,
273
+ });
274
+ }
275
+ const encoder = new TextEncoder();
276
+ function base64url(bytes) {
277
+ return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
278
+ }
279
+ /** A short SHA-256 fingerprint: 96 bits, as 16 base64url characters. */
280
+ async function fingerprint(value) {
281
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify(value))));
282
+ return base64url(digest.subarray(0, 12));
283
+ }
284
+ function encodeCursor(resume) {
285
+ const { token, skip, size, scope, page, last } = resume;
286
+ return base64url(encoder.encode(JSON.stringify([token, skip, size, scope, page, last])));
287
+ }
288
+ const FINGERPRINT = /^[A-Za-z0-9_-]{16}$/;
289
+ function decodeCursor(cursor) {
290
+ try {
291
+ const base = cursor.replace(/-/g, "+").replace(/_/g, "/");
292
+ const json = new TextDecoder().decode(fromBase64(base + "=".repeat((4 - (base.length % 4)) % 4)));
293
+ const [token, skip, size, scope, page, last] = JSON.parse(json);
294
+ const midPage = typeof page === "string" && FINGERPRINT.test(page) && typeof last === "string" && FINGERPRINT.test(last);
295
+ if ((token === null || (typeof token === "string" && token !== "")) &&
296
+ Number.isSafeInteger(skip) &&
297
+ skip >= 0 &&
298
+ Number.isSafeInteger(size) &&
299
+ size >= 1 &&
300
+ size <= MAX_PAGE_SIZE &&
301
+ typeof scope === "string" &&
302
+ FINGERPRINT.test(scope) &&
303
+ // A cursor inside a page proves which page; one at a boundary has nothing to prove.
304
+ (skip > 0 ? midPage : page === null && last === null)) {
305
+ return {
306
+ token: token,
307
+ skip: skip,
308
+ size: size,
309
+ scope,
310
+ page: midPage ? page : null,
311
+ last: midPage ? last : null,
312
+ };
313
+ }
314
+ }
315
+ catch {
316
+ // Reported below, as any other cursor this connection did not issue.
317
+ }
318
+ throw new ConnectorCallError("invalid_args", "cursor is not a page.nextCursor this connection issued; pass it back unchanged, or omit it to start over.");
319
+ }
320
+ /**
321
+ * Whether a cursor carrying this page token stays within
322
+ * {@link MAX_CURSOR_CHARS} wherever it resumes, mid-page fingerprints included.
323
+ */
324
+ function issuable(token) {
325
+ const widest = "A".repeat(16);
326
+ return (encodeCursor({ token, skip: MAX_PAGE_SIZE, size: MAX_PAGE_SIZE, scope: widest, page: widest, last: widest })
327
+ .length <= MAX_CURSOR_CHARS);
328
+ }
329
+ /**
330
+ * One page of a listing, as many rows as fit `maxBytes` with the whole
331
+ * result — envelope and cursor included — and a cursor that resumes at the
332
+ * first row left out, inside Drive's page or after it.
333
+ *
334
+ * Callers `await` it where they return it: a cursor refused before the first
335
+ * await rejects at once, and workerd reports a rejected promise an async
336
+ * handler merely returns as unhandled before the handler adopts it.
337
+ */
338
+ async function pageOf(key, args,
339
+ /** The tool and every argument that decides which rows, in which order. */
340
+ binding, fetch) {
341
+ const maxBytes = args["maxBytes"] ?? DEFAULT_RESULT_BYTES;
342
+ const size = args["limit"] ?? DEFAULT_PAGE_SIZE;
343
+ const scope = await fingerprint({ ...binding, size });
344
+ const resume = typeof args["cursor"] === "string"
345
+ ? decodeCursor(args["cursor"])
346
+ : { token: null, skip: 0, size, scope, page: null, last: null };
347
+ if (resume.scope !== scope) {
348
+ throw new ConnectorCallError("invalid_args", "This cursor belongs to a different listing — another tool, or other arguments. Pass the same arguments it was issued with, or omit cursor to start over.");
349
+ }
350
+ const fetched = await fetch(resume.token ?? undefined, resume.size);
351
+ if (fetched.next !== undefined && !issuable(fetched.next)) {
352
+ throw new ConnectorCallError("connector_call_failed", `Drive answered with a next-page token of ${fetched.next.length} characters, too long for a cursor this connection can accept back (${MAX_CURSOR_CHARS}), so the listing cannot be resumed past this page. Narrow the query, or raise limit to take more per page.`, { retryable: false });
353
+ }
354
+ // Drive's ids as Drive sent them, so two that differ anywhere differ here.
355
+ const page = await fingerprint(fetched.ids);
356
+ const lasts = await Promise.all(fetched.ids.map((id) => fingerprint(id)));
357
+ if (resume.skip > 0 && (page !== resume.page || lasts[resume.skip - 1] !== resume.last)) {
358
+ throw new ConnectorCallError("conflict", `The Drive page this cursor resumes inside has changed since it was read — ${key} were added, removed, or reordered — so resuming by position could skip or repeat some. Nothing was returned; start the listing again without cursor.`, { retryable: false });
359
+ }
360
+ const rows = fetched.rows.slice(resume.skip);
361
+ const build = (count) => {
362
+ const nextCursor = count < rows.length
363
+ ? encodeCursor({ ...resume, skip: resume.skip + count, page, last: lasts[resume.skip + count - 1] ?? "" })
364
+ : fetched.next
365
+ ? encodeCursor({ token: fetched.next, skip: 0, size: resume.size, scope, page: null, last: null })
366
+ : null;
367
+ return { [key]: rows.slice(0, count), ...fetched.extra, page: { hasMore: nextCursor !== null, nextCursor } };
368
+ };
369
+ if (jsonBytes(build(rows.length)) <= maxBytes)
370
+ return build(rows.length);
371
+ // The most rows that fit, by binary search; a result's size grows with them.
372
+ let low = 0;
373
+ let high = rows.length - 1;
374
+ while (low < high) {
375
+ const middle = Math.ceil((low + high) / 2);
376
+ if (jsonBytes(build(middle)) <= maxBytes)
377
+ low = middle;
378
+ else
379
+ high = middle - 1;
380
+ }
381
+ if (low === 0) {
382
+ // Every row is bounded well under the smallest maxBytes; only a cursor
383
+ // stuck on a row that never fits could land here, and it must not loop.
384
+ throw new ConnectorCallError("connector_call_failed", `One ${key} row does not fit this result's maxBytes of ${maxBytes}; raise maxBytes.`, { retryable: false });
385
+ }
386
+ return build(low);
387
+ }
388
+ // --- Content ----------------------------------------------------------------------
389
+ const TEXT_TYPES = new Set([
390
+ "application/json",
391
+ "application/xml",
392
+ "application/javascript",
393
+ "application/x-javascript",
394
+ "application/x-yaml",
395
+ "application/yaml",
396
+ "application/x-sh",
397
+ "application/sql",
398
+ "application/csv",
399
+ "application/x-ndjson",
400
+ "image/svg+xml",
401
+ ]);
402
+ function isText(mimeType) {
403
+ const type = mimeType.toLowerCase();
404
+ return type.startsWith("text/") || TEXT_TYPES.has(type) || type.endsWith("+json") || type.endsWith("+xml");
405
+ }
406
+ /** Whether the UTF-16 unit at `index` opens a surrogate pair that is whole. */
407
+ function pairAt(value, index) {
408
+ const unit = value.charCodeAt(index);
409
+ return unit >= 0xd800 && unit <= 0xdbff && (value.charCodeAt(index + 1) & 0xfc00) === 0xdc00;
410
+ }
411
+ /**
412
+ * The string index just past the first `max` characters, counted as code
413
+ * points — so a cut never splits a surrogate pair — and how many characters
414
+ * the whole string holds.
415
+ */
416
+ function codePointCut(value, max) {
417
+ let index = 0;
418
+ let total = 0;
419
+ let end = value.length;
420
+ while (index < value.length) {
421
+ if (total === max)
422
+ end = index;
423
+ index += pairAt(value, index) ? 2 : 1;
424
+ total += 1;
425
+ }
426
+ return { end: total > max ? end : value.length, total };
427
+ }
428
+ /**
429
+ * Cut text to what both bounds allow and say so in the text itself: `max`
430
+ * characters (code points, so never inside a pair), and whatever `fits` — the
431
+ * whole result under `maxBytes` — admits, marker included. `partial` is a body
432
+ * read only in part, which is cut whatever its length: there is more past it.
433
+ */
434
+ function capped(body, max, partial, fits) {
435
+ const { total } = codePointCut(body, max);
436
+ if (total <= max && !partial && fits(body))
437
+ return { content: body, contentTruncated: false };
438
+ const of = partial
439
+ ? partial.size !== undefined && partial.size > 0
440
+ ? `a ${partial.size}-byte file`
441
+ : "a larger file"
442
+ : `${total} characters`;
443
+ const cut = (count, raise) => `${body.slice(0, codePointCut(body, count).end)}\n[… truncated: ${count} characters of ${of} shown; raise ${raise} to read more]`;
444
+ const byChars = cut(Math.min(total, max), `maxChars (up to ${MAX_CONTENT_CHARS})`);
445
+ if (fits(byChars))
446
+ return { content: byChars, contentTruncated: true };
447
+ // The bytes bind first: the most characters that fit, by binary search.
448
+ const raise = `maxBytes (up to ${MAX_RESULT_BYTES}, for a direct call_tool only)`;
449
+ let low = 0;
450
+ let high = Math.min(total, max);
451
+ while (low < high) {
452
+ const middle = Math.ceil((low + high) / 2);
453
+ if (fits(cut(middle, raise)))
454
+ low = middle;
455
+ else
456
+ high = middle - 1;
457
+ }
458
+ return { content: cut(low, raise), contentTruncated: true };
459
+ }
460
+ function charsetOf(contentType) {
461
+ return /charset="?([^";\s]+)"?/i.exec(contentType ?? "")?.[1] ?? "utf-8";
462
+ }
463
+ /** Bytes as text in the charset the response declared, where the runtime knows it. */
464
+ function decodeIn(bytes, contentType) {
465
+ try {
466
+ return new TextDecoder(charsetOf(contentType)).decode(bytes);
467
+ }
468
+ catch {
469
+ return new TextDecoder().decode(bytes);
470
+ }
471
+ }
472
+ function base64(bytes) {
473
+ let binary = "";
474
+ for (let index = 0; index < bytes.length; index += 0x8000) {
475
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
476
+ }
477
+ return btoa(binary);
478
+ }
479
+ function fromBase64(value) {
480
+ let binary;
481
+ try {
482
+ binary = atob(value);
483
+ }
484
+ catch {
485
+ throw new ConnectorCallError("invalid_args", "contentBase64 is not valid base64.");
486
+ }
487
+ const bytes = new Uint8Array(binary.length);
488
+ for (let index = 0; index < binary.length; index += 1)
489
+ bytes[index] = binary.charCodeAt(index);
490
+ return bytes;
491
+ }
492
+ /**
493
+ * Why the content tool returns no content for a file, as the note says it.
494
+ * Only a well-formed id or media type is interpolated; anything else Drive
495
+ * sends is named by kind, so a note stays a sentence.
496
+ */
497
+ function unreadable(file) {
498
+ const mimeType = String(file["mimeType"] ?? "");
499
+ if (mimeType === FOLDER)
500
+ return "A folder has no content; list it with list_folder_items.";
501
+ if (mimeType === SHORTCUT) {
502
+ const target = text(asRecord(file["shortcutDetails"])["targetId"]);
503
+ const named = target !== undefined && DRIVE_ID.test(target) ? ` (${target})` : "";
504
+ return `A shortcut has no content of its own; read its target${named} instead.`;
505
+ }
506
+ if (mimeType === "application/vnd.google-apps.drawing") {
507
+ return "Drawings export only as images or PDF; this connection reads no drawing. Open webViewLink.";
508
+ }
509
+ if (mimeType.startsWith(NATIVE) && !EXPORTS[mimeType]) {
510
+ const named = mimeType.length <= 255 && MEDIA_TYPE.test(mimeType) ? mimeType : "this Google file type";
511
+ return `Drive exports no text for ${named}; open webViewLink.`;
512
+ }
513
+ if (asRecord(file["capabilities"])["canDownload"] === false) {
514
+ return "The owner has disabled download, print, and copy for this account; Drive refuses its content.";
515
+ }
516
+ return undefined;
517
+ }
518
+ /**
519
+ * The bytes a text read keeps: enough for `maxChars` of UTF-8 at four bytes
520
+ * each and one more character, so a body this long is always cut.
521
+ */
522
+ function textBudget(maxChars) {
523
+ return maxChars * 4 + 4;
524
+ }
525
+ /**
526
+ * A media download of at most `limit` bytes, and whether the file had more.
527
+ *
528
+ * The limit is enforced on the response, never on the size metadata
529
+ * reported: that is a separate read, and a file can grow, shrink, or empty
530
+ * between the two. The range asks for one byte past the limit, so a file that
531
+ * has more says so by sending it, and the shared client's `maxBytes` reads the
532
+ * body only that far and cancels the rest unread — so a server that ignores
533
+ * the range, or a body past the client's own ceiling, costs no more than the
534
+ * limit either.
535
+ *
536
+ * The range is sent even when the metadata says the file is empty, since that
537
+ * may no longer be so. A range from byte 0 of a file with no bytes is answered
538
+ * 416 with `Content-Range: bytes *\/0`, which the client returns as an empty
539
+ * body: a verified empty file, not a failure. Any other 416 still fails.
540
+ */
541
+ async function download(client, ctx, path, limit) {
542
+ const read = await client.bytes({
543
+ method: "GET",
544
+ path,
545
+ query: { ...ALL_DRIVES, alt: "media" },
546
+ headers: { Range: `bytes=0-${limit}` },
547
+ }, ctx, "*/*", { maxBytes: limit });
548
+ return { bytes: read.bytes.subarray(0, limit), more: read.truncated, contentType: read.contentType };
549
+ }
550
+ async function readContent(client, ctx, fileId, maxChars, maxBytes) {
551
+ const filePath = `/files/${encodeURIComponent(fileId)}`;
552
+ const file = asRecord(await client.json({
553
+ method: "GET",
554
+ path: filePath,
555
+ query: {
556
+ ...ALL_DRIVES,
557
+ fields: "id,name,mimeType,size,webViewLink,capabilities(canDownload),shortcutDetails(targetId)",
558
+ },
559
+ }, ctx));
560
+ const mimeType = String(file["mimeType"] ?? "application/octet-stream");
561
+ const size = sizeOf(file["size"]);
562
+ const cuts = new Cuts();
563
+ const base = compact({
564
+ id: cuts.id("id", file["id"]) ?? fileId,
565
+ name: cuts.label("name", file["name"], ROW_NAME_BYTES),
566
+ mimeType: cuts.label("mimeType", mimeType),
567
+ size,
568
+ webViewLink: cuts.label("webViewLink", file["webViewLink"]),
569
+ truncatedFields: cuts.fields,
570
+ });
571
+ const refusal = unreadable(file);
572
+ if (refusal)
573
+ return { ...base, format: "unavailable", contentTruncated: false, note: refusal };
574
+ /** Whether the whole result, with this content, stays under maxBytes. */
575
+ const within = (result) => (content) => jsonBytes({ ...result, content }) <= maxBytes;
576
+ const exported = EXPORTS[mimeType];
577
+ if (exported) {
578
+ // Past Drive's 10 MB export limit, the shared client names Google's
579
+ // exportSizeLimitExceeded itself; nothing here guesses at a 403.
580
+ const { text: body } = await client.text({ method: "GET", path: `${filePath}/export`, query: { mimeType: exported.mimeType } }, ctx, exported.mimeType);
581
+ const result = compact({
582
+ ...base,
583
+ format: exported.format,
584
+ exportedAs: exported.mimeType,
585
+ content: "",
586
+ contentTruncated: false,
587
+ note: exported.note,
588
+ });
589
+ return { ...result, ...capped(body, maxChars, undefined, within(result)) };
590
+ }
591
+ if (isText(mimeType)) {
592
+ const read = await download(client, ctx, filePath, textBudget(maxChars));
593
+ let body = decodeIn(read.bytes, read.contentType);
594
+ // A cut can land inside a character; drop the half, never show it.
595
+ if (read.more)
596
+ body = body.replace(/(?:\uFFFD|[\uD800-\uDBFF])$/, "");
597
+ const result = { ...base, format: "text", content: "", contentTruncated: false };
598
+ return { ...result, ...capped(body, maxChars, read.more ? { size } : undefined, within(result)) };
599
+ }
600
+ // Base64 is four characters per three bytes and never escaped, so the file
601
+ // bytes that fit are known before anything is downloaded.
602
+ const envelope = jsonBytes({ ...base, format: "base64", content: "", contentTruncated: false });
603
+ const fitting = Math.floor(Math.max(0, maxBytes - envelope) / 4) * 3;
604
+ const limit = Math.min(BINARY_INLINE_BYTES, fitting);
605
+ const tooLarge = (bytes) => ({
606
+ ...base,
607
+ format: "unavailable",
608
+ contentTruncated: false,
609
+ note: limit < BINARY_INLINE_BYTES
610
+ ? `A binary file of ${bytes} does not fit this result's maxBytes (${maxBytes}), which holds ${limit} bytes of file; no content is returned. Raise maxBytes, up to ${MAX_RESULT_BYTES} for a direct call_tool only, to read up to ${BINARY_INLINE_BYTES} bytes, or open webViewLink.`
611
+ : `A binary file of ${bytes} is past this connection's ${BINARY_INLINE_BYTES}-byte inline cap; no content is returned. Open webViewLink.`,
612
+ });
613
+ // Metadata that already says too large saves the download; metadata that
614
+ // says small is not trusted, and the download is held to the cap itself.
615
+ if (size !== undefined && size > limit)
616
+ return tooLarge(`${size} bytes`);
617
+ const read = await download(client, ctx, filePath, limit);
618
+ if (read.more)
619
+ return tooLarge(`more than ${limit} bytes`);
620
+ return { ...base, format: "base64", content: base64(read.bytes), contentTruncated: false };
621
+ }
622
+ // --- Writes -----------------------------------------------------------------------
623
+ /**
624
+ * Run a write that adds something, saying what to check before repeating it
625
+ * when it may have landed: whenever the request left connecta and Google did
626
+ * not refuse it. That is no answer (`awaiting-response`), an unfollowed
627
+ * redirect (`redirected`), a 5xx (`server-error`) — each of which leaves the
628
+ * outcome unknown — or a 2xx whose reply broke (`reading-body`), which
629
+ * probably applied. The shared client already says which, in its own words,
630
+ * and makes the failure non-retryable; this adds only the read that tells,
631
+ * because a repeat of an additive write is not harmless — it makes a second
632
+ * file, folder, copy, or share. The code is kept, so core still reads the
633
+ * outcome as unknown. A refusal, or a failure before anything was sent,
634
+ * passes through untouched.
635
+ */
636
+ async function adding(work, check) {
637
+ try {
638
+ return await work();
639
+ }
640
+ catch (error) {
641
+ const outcome = googleOutcomeOf(error);
642
+ if (error instanceof ConnectorCallError &&
643
+ error.code === "connector_call_failed" &&
644
+ outcome?.dispatched === true &&
645
+ outcome.phase !== "refused" &&
646
+ outcome.phase !== "before-send") {
647
+ throw new ConnectorCallError(error.code, `${error.message} ${check}`, { retryable: false, cause: error });
648
+ }
649
+ throw error;
650
+ }
651
+ }
652
+ /**
653
+ * Writes safe to send twice: each sets fixed values — a name, a description,
654
+ * the trashed flag, a role, the whole content — so a second identical request
655
+ * leaves the file as the first did, and a 5xx stays a retryable outage. Never
656
+ * a create, copy, share, permission delete, or move, whose second send makes a
657
+ * second thing, fails differently, or acts on parents the first send changed.
658
+ */
659
+ const IDEMPOTENT = { idempotent: true };
660
+ const CHECK_CREATED = "Find it with search_files or list_folder_items before creating it again: a second call makes a second one.";
661
+ // --- Uploads ----------------------------------------------------------------------
662
+ const MEDIA_TYPE = /^[A-Za-z0-9][\w.+-]*\/[A-Za-z0-9][\w.+-]*$/;
663
+ function mediaType(value, fallback) {
664
+ const type = typeof value === "string" ? value : fallback;
665
+ if (!MEDIA_TYPE.test(type)) {
666
+ throw new ConnectorCallError("invalid_args", "mimeType must be a bare media type such as text/plain.");
667
+ }
668
+ if (type.startsWith(NATIVE)) {
669
+ throw new ConnectorCallError("invalid_args", "mimeType names the uploaded content, never a Google type; use convertTo to create a Google Doc, Sheet, or Slides file, or create_folder for a folder.");
670
+ }
671
+ return type;
672
+ }
673
+ /** The uploaded bytes and their media type, from exactly one of the two fields. */
674
+ function media(args) {
675
+ const hasText = typeof args["content"] === "string";
676
+ const hasBinary = typeof args["contentBase64"] === "string";
677
+ if (hasText && hasBinary) {
678
+ throw new ConnectorCallError("invalid_args", "Pass content or contentBase64, not both.");
679
+ }
680
+ if (hasText) {
681
+ return {
682
+ bytes: new TextEncoder().encode(args["content"]),
683
+ type: mediaType(args["mimeType"], "text/plain"),
684
+ };
685
+ }
686
+ if (hasBinary) {
687
+ const bytes = fromBase64(args["contentBase64"]);
688
+ if (bytes.length > BINARY_INLINE_BYTES) {
689
+ throw new ConnectorCallError("invalid_args", `contentBase64 decodes to ${bytes.length} bytes, past this connection's ${BINARY_INLINE_BYTES}-byte upload cap.`);
690
+ }
691
+ return { bytes, type: mediaType(args["mimeType"], "application/octet-stream") };
692
+ }
693
+ return undefined;
694
+ }
695
+ /**
696
+ * One `multipart/related` body: the metadata as JSON, then the media. Built
697
+ * whole as bytes, never a stream, so the shared client's single 401 replay
698
+ * can send it again unchanged.
699
+ */
700
+ function multipart(metadata, content) {
701
+ const boundary = `connecta-${crypto.randomUUID()}`;
702
+ const encoder = new TextEncoder();
703
+ const head = encoder.encode(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n` +
704
+ `--${boundary}\r\nContent-Type: ${content.type}\r\n\r\n`);
705
+ const tail = encoder.encode(`\r\n--${boundary}--\r\n`);
706
+ const body = new Uint8Array(head.length + content.bytes.length + tail.length);
707
+ body.set(head, 0);
708
+ body.set(content.bytes, head.length);
709
+ body.set(tail, head.length + content.bytes.length);
710
+ return { body, contentType: `multipart/related; boundary=${boundary}` };
711
+ }
712
+ /** The upload host beside an API root: `…/drive/v3` → `…/upload/drive/v3`. */
713
+ function uploadBaseUrl(apiBase) {
714
+ const url = new URL(apiBase);
715
+ return `${url.origin}/upload${url.pathname.replace(/\/+$/, "")}`;
716
+ }
717
+ // --- Schemas ----------------------------------------------------------------------
718
+ function input(properties, required) {
719
+ return { type: "object", properties, required, additionalProperties: false };
720
+ }
721
+ const ID_PATTERN = "^[A-Za-z0-9_-]+$";
722
+ function idProperty(description) {
723
+ return { type: "string", minLength: 1, maxLength: 256, pattern: ID_PATTERN, description };
724
+ }
725
+ const FILE_ID = idProperty("File or folder id, from search_files, list_folder_items, or a Drive URL's /d/<id>.");
726
+ const CURSOR_PROPERTY = {
727
+ type: "string",
728
+ minLength: 1,
729
+ maxLength: MAX_CURSOR_CHARS,
730
+ pattern: "^[A-Za-z0-9_-]+$",
731
+ description: "Opaque page.nextCursor from the previous page. Pass it back unchanged with the same arguments.",
732
+ };
733
+ const MAX_BYTES_PROPERTY = {
734
+ type: "integer",
735
+ minimum: MIN_RESULT_BYTES,
736
+ maximum: MAX_RESULT_BYTES,
737
+ description: `Serialized bytes of the whole result; defaults to ${DEFAULT_RESULT_BYTES}, what execute_code can receive. Raise it, to ${MAX_RESULT_BYTES}, only for a direct call_tool read.`,
738
+ };
739
+ const LIMIT_PROPERTY = {
740
+ type: "integer",
741
+ minimum: 1,
742
+ maximum: MAX_PAGE_SIZE,
743
+ description: `Rows per page, 1 to ${MAX_PAGE_SIZE}; defaults to ${DEFAULT_PAGE_SIZE}. Connecta's cap, below Drive's 1,000.`,
744
+ };
745
+ const ORDER_PROPERTY = {
746
+ type: "string",
747
+ enum: ["modifiedTime desc", "modifiedTime", "name", "name desc", "createdTime desc", "folder,name"],
748
+ description: "Sort order. Drive refuses any sort on a fullText query, which comes back by relevance.",
749
+ };
750
+ const INCLUDE_TRASHED = {
751
+ type: "boolean",
752
+ description: "Include trashed files, which are left out by default.",
753
+ };
754
+ const NAME_PROPERTY = {
755
+ type: "string",
756
+ minLength: 1,
757
+ maxLength: 1000,
758
+ pattern: "^[^\\r\\n]*$",
759
+ description: "File name as Drive shows it, extension included where it has one.",
760
+ };
761
+ const DESCRIPTION_PROPERTY = {
762
+ type: "string",
763
+ maxLength: 10_000,
764
+ description: "Free-text description shown in Drive's details pane.",
765
+ };
766
+ const PARENT_PROPERTY = idProperty("Folder id to place it in, or root for My Drive; defaults to My Drive.");
767
+ const CONTENT_PROPERTY = {
768
+ type: "string",
769
+ maxLength: MAX_UPLOAD_CHARS,
770
+ description: `Text content, up to ${MAX_UPLOAD_CHARS} characters (connecta's cap). Exclusive with contentBase64.`,
771
+ };
772
+ const CONTENT_BASE64_PROPERTY = {
773
+ type: "string",
774
+ maxLength: MAX_UPLOAD_BASE64,
775
+ pattern: "^[A-Za-z0-9+/]*={0,2}$",
776
+ description: `Binary content as base64, up to ${BINARY_INLINE_BYTES} bytes decoded (connecta's cap; Drive allows 5 MB). Exclusive with content.`,
777
+ };
778
+ const MIME_PROPERTY = {
779
+ type: "string",
780
+ maxLength: 255,
781
+ pattern: "^[A-Za-z0-9][\\w.+-]*/[A-Za-z0-9][\\w.+-]*$",
782
+ description: "Media type of the content: text/plain, text/markdown, text/csv, application/pdf. Never a Google type.",
783
+ };
784
+ const ROLE_PROPERTY = {
785
+ type: "string",
786
+ enum: ["reader", "commenter", "writer", "fileOrganizer", "organizer"],
787
+ description: "Access granted. fileOrganizer and organizer exist only on shared drives. Ownership is never granted here.",
788
+ };
789
+ const PERMISSION_ID = idProperty("Permission id from list_permissions.");
790
+ const TRUNCATED_FIELDS = {
791
+ type: "array",
792
+ items: { type: "string" },
793
+ description: "Fields this result shortened (a string ending in …), capped (a list past ten), or left out (a malformed id). get_file keeps longer names.",
794
+ };
795
+ const PAGE_SCHEMA = {
796
+ type: "object",
797
+ properties: {
798
+ hasMore: { type: "boolean" },
799
+ nextCursor: { type: ["string", "null"] },
800
+ },
801
+ required: ["hasMore", "nextCursor"],
802
+ };
803
+ const FILE_PROPERTIES = {
804
+ id: { type: "string" },
805
+ name: { type: "string" },
806
+ mimeType: { type: "string" },
807
+ parents: { type: "array", items: { type: "string" } },
808
+ driveId: { type: "string" },
809
+ size: { type: "integer" },
810
+ modifiedTime: { type: "string" },
811
+ owners: { type: "array", items: { type: "string" } },
812
+ trashed: { type: "boolean" },
813
+ webViewLink: { type: "string" },
814
+ shortcutTargetId: { type: "string" },
815
+ shortcutTargetMimeType: { type: "string" },
816
+ truncatedFields: TRUNCATED_FIELDS,
817
+ };
818
+ // Nothing is required: Google's ProtoJSON omits an empty field, and a
819
+ // projection says only what the response said.
820
+ const FILE_SCHEMA = { type: "object", properties: FILE_PROPERTIES };
821
+ const FILE_DETAIL_SCHEMA = {
822
+ type: "object",
823
+ properties: {
824
+ ...FILE_PROPERTIES,
825
+ description: { type: "string" },
826
+ createdTime: { type: "string" },
827
+ modifiedBy: { type: "string" },
828
+ shared: { type: "boolean" },
829
+ starred: { type: "boolean" },
830
+ capabilities: {
831
+ type: "object",
832
+ properties: Object.fromEntries(CAPABILITIES.map((name) => [name, { type: "boolean" }])),
833
+ },
834
+ },
835
+ };
836
+ const FILE_LIST_SCHEMA = {
837
+ type: "object",
838
+ properties: {
839
+ files: { type: "array", items: FILE_SCHEMA },
840
+ incompleteSearch: { type: "boolean" },
841
+ page: PAGE_SCHEMA,
842
+ },
843
+ required: ["files", "page"],
844
+ };
845
+ const PERMISSION_SCHEMA = {
846
+ type: "object",
847
+ properties: {
848
+ id: { type: "string" },
849
+ type: { type: "string", enum: ["user", "group", "domain", "anyone"] },
850
+ role: { type: "string" },
851
+ emailAddress: { type: "string" },
852
+ domain: { type: "string" },
853
+ displayName: { type: "string" },
854
+ allowFileDiscovery: { type: "boolean" },
855
+ expirationTime: { type: "string" },
856
+ inherited: { type: "boolean" },
857
+ inheritedFrom: { type: "string" },
858
+ deleted: { type: "boolean" },
859
+ truncatedFields: TRUNCATED_FIELDS,
860
+ },
861
+ };
862
+ // --- Tools ------------------------------------------------------------------------
863
+ function tools(client, upload) {
864
+ const readOnly = { readOnlyHint: true };
865
+ // Additive: creates something new that discloses nothing, and changes
866
+ // nothing that existed.
867
+ const additive = { readOnlyHint: false, destructiveHint: false };
868
+ // Overwrites, moves, trashes, or changes who can see a file.
869
+ const destructive = { readOnlyHint: false, destructiveHint: true };
870
+ const filePath = (id) => `/files/${encodeURIComponent(id)}`;
871
+ const list = (ctx, tool, args, query) =>
872
+ // The Drive query decides the rows: folder, search, corpus, drive, order.
873
+ pageOf("files", args, { tool, ...query }, async (pageToken, pageSize) => {
874
+ const listing = asRecord(await client.json({
875
+ method: "GET",
876
+ path: "/files",
877
+ query: {
878
+ ...ALL_DRIVES,
879
+ includeItemsFromAllDrives: true,
880
+ fields: `nextPageToken,incompleteSearch,files(${SUMMARY_FIELDS})`,
881
+ ...query,
882
+ pageSize,
883
+ pageToken,
884
+ },
885
+ }, ctx));
886
+ return {
887
+ ids: asArray(listing["files"]).map((file) => String(asRecord(file)["id"] ?? "")),
888
+ rows: asArray(listing["files"]).map((file) => projectFile(file)),
889
+ next: text(listing["nextPageToken"]),
890
+ // Drive's own truncation: it stopped before searching every corpus.
891
+ extra: listing["incompleteSearch"] === true ? { incompleteSearch: true } : undefined,
892
+ };
893
+ });
894
+ const patch = async (ctx, fileId, body, query = {}, options = {}) => projectFile(await client.json({
895
+ method: "PATCH",
896
+ path: filePath(fileId),
897
+ query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS, ...query },
898
+ body,
899
+ }, ctx, options), fileId);
900
+ return [
901
+ {
902
+ name: "search_files",
903
+ description: "Search Drive files the user can open with Drive query syntax, returning name, type, folder, size, and link per file. Content needs get_file_content.",
904
+ annotations: readOnly,
905
+ inputSchema: input({
906
+ query: {
907
+ type: "string",
908
+ maxLength: 2048,
909
+ description: "Drive query: name contains 'budget', fullText contains 'elders', mimeType = 'application/pdf', modifiedTime > '2026-01-01T00:00:00', 'me' in owners. Omit for everything.",
910
+ },
911
+ corpora: {
912
+ type: "string",
913
+ enum: ["user", "domain", "drive", "allDrives"],
914
+ description: "Where to look: user (default; My Drive and shared with me), drive (one shared drive, with driveId), allDrives (slower, may be incomplete), domain.",
915
+ },
916
+ driveId: idProperty("Shared drive id from list_shared_drives; required with corpora drive, refused otherwise."),
917
+ orderBy: ORDER_PROPERTY,
918
+ includeTrashed: INCLUDE_TRASHED,
919
+ limit: LIMIT_PROPERTY,
920
+ cursor: CURSOR_PROPERTY,
921
+ maxBytes: MAX_BYTES_PROPERTY,
922
+ }, []),
923
+ outputSchema: FILE_LIST_SCHEMA,
924
+ handler: async (args, ctx) => {
925
+ const corpora = args["corpora"] ?? "user";
926
+ if ((corpora === "drive") !== (args["driveId"] !== undefined)) {
927
+ throw new ConnectorCallError("invalid_args", "driveId goes with corpora drive and only with it; find shared drive ids with list_shared_drives.");
928
+ }
929
+ const query = text(args["query"]);
930
+ const q = args["includeTrashed"] === true ? query : query ? `(${query}) and trashed = false` : "trashed = false";
931
+ return await list(ctx, "search_files", args, {
932
+ q,
933
+ corpora,
934
+ driveId: args["driveId"],
935
+ orderBy: args["orderBy"],
936
+ });
937
+ },
938
+ },
939
+ {
940
+ name: "list_folder_items",
941
+ description: "List the files and folders directly inside one Drive folder, in My Drive or a shared drive. Not recursive: list a subfolder by its own id.",
942
+ annotations: readOnly,
943
+ inputSchema: input({
944
+ folderId: idProperty("Folder id, a shared drive id for its top level, or root for My Drive."),
945
+ orderBy: { ...ORDER_PROPERTY, description: "Sort order; defaults to folders first, then by name." },
946
+ includeTrashed: INCLUDE_TRASHED,
947
+ limit: LIMIT_PROPERTY,
948
+ cursor: CURSOR_PROPERTY,
949
+ maxBytes: MAX_BYTES_PROPERTY,
950
+ }, ["folderId"]),
951
+ outputSchema: FILE_LIST_SCHEMA,
952
+ handler: async (args, ctx) => {
953
+ const folderId = String(args["folderId"]);
954
+ // A shared drive folder is listed within its drive, which Drive
955
+ // searches only when asked by id; the folder itself says which.
956
+ let driveId;
957
+ if (folderId !== "root") {
958
+ const folder = asRecord(await client.json({
959
+ method: "GET",
960
+ path: filePath(folderId),
961
+ query: { ...ALL_DRIVES, fields: "id,mimeType,driveId" },
962
+ }, ctx));
963
+ if (folder["mimeType"] !== FOLDER) {
964
+ throw new ConnectorCallError("invalid_args", `${folderId} is not a folder (${String(folder["mimeType"] ?? "unknown type")}); read it with get_file or get_file_content.`);
965
+ }
966
+ driveId = text(folder["driveId"]);
967
+ }
968
+ const q = `'${folderId}' in parents${args["includeTrashed"] === true ? "" : " and trashed = false"}`;
969
+ return await list(ctx, "list_folder_items", args, {
970
+ q,
971
+ corpora: driveId ? "drive" : "user",
972
+ driveId,
973
+ orderBy: args["orderBy"] ?? "folder,name",
974
+ });
975
+ },
976
+ },
977
+ {
978
+ name: "get_file",
979
+ description: "Get one Drive file's metadata: name, type, folder, owners, sharing flag, description, and what this user may do with it. Never its content.",
980
+ annotations: readOnly,
981
+ inputSchema: input({ fileId: FILE_ID }, ["fileId"]),
982
+ outputSchema: FILE_DETAIL_SCHEMA,
983
+ handler: async (args, ctx) => projectFileDetail(await client.json({
984
+ method: "GET",
985
+ path: filePath(args["fileId"]),
986
+ query: { ...ALL_DRIVES, fields: DETAIL_FIELDS },
987
+ }, ctx), String(args["fileId"])),
988
+ },
989
+ {
990
+ name: "get_file_content",
991
+ description: "Read a Drive file's content: Docs as Markdown, Sheets as CSV of the first sheet, Slides as text, text files as text, small binaries as base64. Never drawings or folders.",
992
+ annotations: readOnly,
993
+ inputSchema: input({
994
+ fileId: FILE_ID,
995
+ maxChars: {
996
+ type: "integer",
997
+ minimum: 1,
998
+ maximum: MAX_CONTENT_CHARS,
999
+ description: `Characters (code points) of text kept, 1 to ${MAX_CONTENT_CHARS}; defaults to ${DEFAULT_CONTENT_CHARS}. Longer text ends with a truncation marker. Binaries ignore it.`,
1000
+ },
1001
+ maxBytes: MAX_BYTES_PROPERTY,
1002
+ }, ["fileId"]),
1003
+ outputSchema: {
1004
+ type: "object",
1005
+ properties: {
1006
+ id: { type: "string" },
1007
+ name: { type: "string" },
1008
+ mimeType: { type: "string" },
1009
+ size: { type: "integer" },
1010
+ truncatedFields: TRUNCATED_FIELDS,
1011
+ webViewLink: { type: "string" },
1012
+ format: { type: "string", enum: ["markdown", "csv", "text", "base64", "unavailable"] },
1013
+ exportedAs: { type: "string" },
1014
+ content: { type: "string" },
1015
+ contentTruncated: { type: "boolean" },
1016
+ note: { type: "string" },
1017
+ },
1018
+ required: ["id", "format", "contentTruncated"],
1019
+ },
1020
+ handler: async (args, ctx) => await readContent(client, ctx, String(args["fileId"]), typeof args["maxChars"] === "number" ? args["maxChars"] : DEFAULT_CONTENT_CHARS, typeof args["maxBytes"] === "number" ? args["maxBytes"] : DEFAULT_RESULT_BYTES),
1021
+ },
1022
+ {
1023
+ name: "list_permissions",
1024
+ description: "List who a Drive file or folder is shared with: each permission's id, type, role, and address or domain. Use the ids with update_permission or delete_permission.",
1025
+ annotations: readOnly,
1026
+ inputSchema: input({ fileId: FILE_ID, limit: LIMIT_PROPERTY, cursor: CURSOR_PROPERTY, maxBytes: MAX_BYTES_PROPERTY }, ["fileId"]),
1027
+ outputSchema: {
1028
+ type: "object",
1029
+ properties: { permissions: { type: "array", items: PERMISSION_SCHEMA }, page: PAGE_SCHEMA },
1030
+ required: ["permissions", "page"],
1031
+ },
1032
+ handler: async (args, ctx) => await pageOf("permissions", args, { tool: "list_permissions", fileId: args["fileId"] }, async (pageToken, pageSize) => {
1033
+ const listing = asRecord(await client.json({
1034
+ method: "GET",
1035
+ path: `${filePath(args["fileId"])}/permissions`,
1036
+ query: {
1037
+ ...ALL_DRIVES,
1038
+ fields: `nextPageToken,permissions(${PERMISSION_FIELDS})`,
1039
+ pageSize,
1040
+ pageToken,
1041
+ },
1042
+ }, ctx));
1043
+ return {
1044
+ ids: asArray(listing["permissions"]).map((permission) => String(asRecord(permission)["id"] ?? "")),
1045
+ rows: asArray(listing["permissions"]).map((permission) => projectPermission(permission)),
1046
+ next: text(listing["nextPageToken"]),
1047
+ };
1048
+ }),
1049
+ },
1050
+ {
1051
+ name: "list_shared_drives",
1052
+ description: "List the shared drives the user is a member of, with their ids for search_files and list_folder_items. Not the files inside them.",
1053
+ annotations: readOnly,
1054
+ inputSchema: input({
1055
+ query: {
1056
+ type: "string",
1057
+ maxLength: 1024,
1058
+ description: "Shared drive query, e.g. name contains 'Finance'. Omit for every drive.",
1059
+ },
1060
+ limit: LIMIT_PROPERTY,
1061
+ cursor: CURSOR_PROPERTY,
1062
+ maxBytes: MAX_BYTES_PROPERTY,
1063
+ }, []),
1064
+ outputSchema: {
1065
+ type: "object",
1066
+ properties: {
1067
+ drives: {
1068
+ type: "array",
1069
+ items: {
1070
+ type: "object",
1071
+ properties: {
1072
+ id: { type: "string" },
1073
+ name: { type: "string" },
1074
+ hidden: { type: "boolean" },
1075
+ truncatedFields: TRUNCATED_FIELDS,
1076
+ },
1077
+ },
1078
+ },
1079
+ page: PAGE_SCHEMA,
1080
+ },
1081
+ required: ["drives", "page"],
1082
+ },
1083
+ handler: async (args, ctx) => await pageOf("drives", args, { tool: "list_shared_drives", q: args["query"] }, async (pageToken, pageSize) => {
1084
+ const listing = asRecord(await client.json({
1085
+ method: "GET",
1086
+ path: "/drives",
1087
+ query: {
1088
+ q: args["query"],
1089
+ fields: "nextPageToken,drives(id,name,hidden)",
1090
+ pageSize,
1091
+ pageToken,
1092
+ },
1093
+ }, ctx));
1094
+ return {
1095
+ ids: asArray(listing["drives"]).map((value) => String(asRecord(value)["id"] ?? "")),
1096
+ rows: asArray(listing["drives"]).map((value) => {
1097
+ const sharedDrive = asRecord(value);
1098
+ const cuts = new Cuts();
1099
+ return compact({
1100
+ id: cuts.id("id", sharedDrive["id"]),
1101
+ name: cuts.label("name", sharedDrive["name"]),
1102
+ hidden: sharedDrive["hidden"] === true ? true : undefined,
1103
+ truncatedFields: cuts.fields,
1104
+ });
1105
+ }),
1106
+ next: text(listing["nextPageToken"]),
1107
+ };
1108
+ }),
1109
+ },
1110
+ {
1111
+ name: "create_folder",
1112
+ description: "Create a new, empty Drive folder, in My Drive or inside another folder. Shares nothing beyond what the parent folder already grants.",
1113
+ annotations: additive,
1114
+ inputSchema: input({ name: NAME_PROPERTY, parentId: PARENT_PROPERTY, description: DESCRIPTION_PROPERTY }, [
1115
+ "name",
1116
+ ]),
1117
+ outputSchema: FILE_SCHEMA,
1118
+ handler: async (args, ctx) => projectFile(await adding(() => client.json({
1119
+ method: "POST",
1120
+ path: "/files",
1121
+ query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS },
1122
+ body: compact({
1123
+ name: args["name"],
1124
+ mimeType: FOLDER,
1125
+ parents: args["parentId"] ? [args["parentId"]] : undefined,
1126
+ description: args["description"],
1127
+ }),
1128
+ }, ctx), CHECK_CREATED)),
1129
+ },
1130
+ {
1131
+ name: "create_file",
1132
+ description: "Create a Drive file from text or base64 content, or an empty one; convertTo makes a Google Doc, Sheet, or Slides file. Whoever can see its folder can see it.",
1133
+ // Destructive though nothing is overwritten: a new file takes its
1134
+ // folder's sharing, so content written into a shared folder is
1135
+ // disclosed to everyone it is shared with, at once.
1136
+ annotations: destructive,
1137
+ inputSchema: input({
1138
+ name: NAME_PROPERTY,
1139
+ parentId: PARENT_PROPERTY,
1140
+ content: CONTENT_PROPERTY,
1141
+ contentBase64: CONTENT_BASE64_PROPERTY,
1142
+ mimeType: {
1143
+ ...MIME_PROPERTY,
1144
+ description: `${MIME_PROPERTY.description} Defaults to text/plain or application/octet-stream.`,
1145
+ },
1146
+ convertTo: {
1147
+ type: "string",
1148
+ enum: ["document", "spreadsheet", "presentation"],
1149
+ description: "Import as a Google type: markdown, HTML, or text to document; CSV to spreadsheet. Without content, an empty one.",
1150
+ },
1151
+ description: DESCRIPTION_PROPERTY,
1152
+ }, ["name"]),
1153
+ outputSchema: FILE_SCHEMA,
1154
+ handler: async (args, ctx) => {
1155
+ const content = media(args);
1156
+ const convert = typeof args["convertTo"] === "string" ? CONVERSIONS[args["convertTo"]] : undefined;
1157
+ const metadata = compact({
1158
+ name: args["name"],
1159
+ parents: args["parentId"] ? [args["parentId"]] : undefined,
1160
+ description: args["description"],
1161
+ mimeType: convert ?? content?.type,
1162
+ });
1163
+ if (!content) {
1164
+ if (args["mimeType"] !== undefined) {
1165
+ throw new ConnectorCallError("invalid_args", "mimeType describes content; pass content or contentBase64 with it.");
1166
+ }
1167
+ return projectFile(await adding(() => client.json({ method: "POST", path: "/files", query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS }, body: metadata }, ctx), CHECK_CREATED));
1168
+ }
1169
+ const framed = multipart(metadata, content);
1170
+ return projectFile(await adding(() => upload.json({
1171
+ method: "POST",
1172
+ path: "/files",
1173
+ query: { ...ALL_DRIVES, uploadType: "multipart", fields: SUMMARY_FIELDS },
1174
+ headers: { "Content-Type": framed.contentType },
1175
+ rawBody: framed.body,
1176
+ }, ctx), CHECK_CREATED));
1177
+ },
1178
+ },
1179
+ {
1180
+ name: "update_file_content",
1181
+ description: "Replace a Drive file's entire content with new text or base64 bytes; the old content survives only in Drive's version history. Keeps name, folder, and sharing.",
1182
+ // Destructive: the whole content is overwritten. Drive keeps revisions
1183
+ // for a while, but this connection offers no way back to one.
1184
+ annotations: destructive,
1185
+ inputSchema: input({
1186
+ fileId: FILE_ID,
1187
+ content: CONTENT_PROPERTY,
1188
+ contentBase64: CONTENT_BASE64_PROPERTY,
1189
+ mimeType: {
1190
+ ...MIME_PROPERTY,
1191
+ description: "Media type of the new content: usually the file's own, text/markdown for a Google Doc, text/csv for a Sheet (replacing every tab).",
1192
+ },
1193
+ }, ["fileId", "mimeType"]),
1194
+ outputSchema: FILE_SCHEMA,
1195
+ handler: async (args, ctx) => {
1196
+ const content = media(args);
1197
+ if (!content) {
1198
+ throw new ConnectorCallError("invalid_args", "Pass content or contentBase64: the file's new content.");
1199
+ }
1200
+ return projectFile(await upload.json({
1201
+ method: "PATCH",
1202
+ path: filePath(args["fileId"]),
1203
+ query: { ...ALL_DRIVES, uploadType: "media", fields: SUMMARY_FIELDS },
1204
+ headers: { "Content-Type": content.type },
1205
+ rawBody: content.bytes,
1206
+ }, ctx,
1207
+ // The whole content, replaced: a second send leaves the same file.
1208
+ IDEMPOTENT), String(args["fileId"]));
1209
+ },
1210
+ },
1211
+ {
1212
+ name: "update_file",
1213
+ description: "Rename a Drive file or folder, or replace its description. Never its content, folder, or sharing: use update_file_content, move_file, or share_file.",
1214
+ // Destructive: the old name or description is overwritten, and a
1215
+ // rename can break what finds the file by name.
1216
+ annotations: destructive,
1217
+ inputSchema: input({ fileId: FILE_ID, name: NAME_PROPERTY, description: DESCRIPTION_PROPERTY }, ["fileId"]),
1218
+ outputSchema: FILE_SCHEMA,
1219
+ handler: async (args, ctx) => {
1220
+ if (args["name"] === undefined && args["description"] === undefined) {
1221
+ throw new ConnectorCallError("invalid_args", "Pass name, description, or both.");
1222
+ }
1223
+ return patch(ctx, args["fileId"], compact({ name: args["name"], description: args["description"] }), {}, IDEMPOTENT);
1224
+ },
1225
+ },
1226
+ {
1227
+ name: "move_file",
1228
+ description: "Move a Drive file or folder into another folder or shared drive, out of the folder it is in now. Can change who has access to it.",
1229
+ // Destructive: a file inherits sharing from its folder, so a move can
1230
+ // revoke access granted through the old folder or grant it through the
1231
+ // new one, and moving into a shared drive hands ownership to the drive.
1232
+ annotations: destructive,
1233
+ inputSchema: input({
1234
+ fileId: FILE_ID,
1235
+ folderId: idProperty("Destination folder id, a shared drive id for its top level, or root for My Drive."),
1236
+ }, ["fileId", "folderId"]),
1237
+ outputSchema: FILE_SCHEMA,
1238
+ handler: async (args, ctx) => {
1239
+ const current = asRecord(await client.json({ method: "GET", path: filePath(args["fileId"]), query: { ...ALL_DRIVES, fields: "parents" } }, ctx));
1240
+ const from = idsOf(current["parents"]).filter((id) => id !== args["folderId"]);
1241
+ return patch(ctx, args["fileId"], {}, {
1242
+ addParents: args["folderId"],
1243
+ removeParents: from.length > 0 ? from.join(",") : undefined,
1244
+ });
1245
+ },
1246
+ },
1247
+ {
1248
+ name: "copy_file",
1249
+ description: "Copy a Drive file into a new file, optionally renamed or in another folder. The copy is shared like its folder, not like the original; no folders.",
1250
+ // Destructive though nothing is overwritten: the copy takes its
1251
+ // destination folder's sharing, so a private file copied into a shared
1252
+ // folder is disclosed — the same exposure move_file carries.
1253
+ annotations: destructive,
1254
+ inputSchema: input({
1255
+ fileId: FILE_ID,
1256
+ name: { ...NAME_PROPERTY, description: 'Name of the copy; defaults to Drive\'s "Copy of …".' },
1257
+ parentId: { ...PARENT_PROPERTY, description: "Folder for the copy; defaults to the original's." },
1258
+ }, ["fileId"]),
1259
+ outputSchema: FILE_SCHEMA,
1260
+ handler: async (args, ctx) => projectFile(await adding(() => client.json({
1261
+ method: "POST",
1262
+ path: `${filePath(args["fileId"])}/copy`,
1263
+ query: { ...ALL_DRIVES, fields: SUMMARY_FIELDS },
1264
+ body: compact({ name: args["name"], parents: args["parentId"] ? [args["parentId"]] : undefined }),
1265
+ }, ctx), "Look for the copy with search_files or list_folder_items before copying again: a second call makes a second copy.")),
1266
+ },
1267
+ {
1268
+ name: "trash_file",
1269
+ description: "Move a Drive file or folder to the trash, where Drive deletes it for good after 30 days; restore_file undoes it. Never deletes permanently itself.",
1270
+ // Destructive: collaborators lose the file at once, and the trash
1271
+ // empties itself.
1272
+ annotations: destructive,
1273
+ inputSchema: input({ fileId: FILE_ID }, ["fileId"]),
1274
+ outputSchema: FILE_SCHEMA,
1275
+ handler: async (args, ctx) => patch(ctx, args["fileId"], { trashed: true }, {}, IDEMPOTENT),
1276
+ },
1277
+ {
1278
+ name: "restore_file",
1279
+ description: "Restore (untrash) a trashed Drive file or folder to where it was. Cannot recover a file already deleted from the trash.",
1280
+ // Additive: brings back exactly what was there, losing nothing.
1281
+ annotations: additive,
1282
+ inputSchema: input({ fileId: FILE_ID }, ["fileId"]),
1283
+ outputSchema: FILE_SCHEMA,
1284
+ handler: async (args, ctx) => patch(ctx, args["fileId"], { trashed: false }, {}, IDEMPOTENT),
1285
+ },
1286
+ {
1287
+ name: "share_file",
1288
+ description: "Share a Drive file or folder with a user, group, domain, or anyone with the link, at a role up to writer. Sends no email unless asked; never transfers ownership.",
1289
+ annotations: destructive,
1290
+ inputSchema: input({
1291
+ fileId: FILE_ID,
1292
+ type: {
1293
+ type: "string",
1294
+ enum: ["user", "group", "domain", "anyone"],
1295
+ description: "Grantee kind: user or group needs emailAddress, domain needs domain, anyone means anyone with the link.",
1296
+ },
1297
+ role: ROLE_PROPERTY,
1298
+ emailAddress: {
1299
+ type: "string",
1300
+ minLength: 3,
1301
+ maxLength: 320,
1302
+ pattern: "^[^\\s@]+@[^\\s@]+$",
1303
+ description: "The user's or group's email address, for type user or group only.",
1304
+ },
1305
+ domain: {
1306
+ type: "string",
1307
+ minLength: 1,
1308
+ maxLength: 253,
1309
+ pattern: "^[A-Za-z0-9.-]+$",
1310
+ description: "The Workspace domain, for type domain only.",
1311
+ },
1312
+ allowFileDiscovery: {
1313
+ type: "boolean",
1314
+ description: "For domain or anyone: whether it appears in search rather than needing the link. Defaults to false.",
1315
+ },
1316
+ sendNotificationEmail: {
1317
+ type: "boolean",
1318
+ description: "Email a user or group that it was shared. Defaults to false; Drive requires it for a non-Google address.",
1319
+ },
1320
+ emailMessage: {
1321
+ type: "string",
1322
+ maxLength: 2000,
1323
+ description: "Message in the notification email; only with sendNotificationEmail true.",
1324
+ },
1325
+ }, ["fileId", "type", "role"]),
1326
+ outputSchema: PERMISSION_SCHEMA,
1327
+ handler: async (args, ctx) => {
1328
+ const type = String(args["type"]);
1329
+ const person = type === "user" || type === "group";
1330
+ const refuse = (message) => {
1331
+ throw new ConnectorCallError("invalid_args", message);
1332
+ };
1333
+ if (person !== (args["emailAddress"] !== undefined))
1334
+ refuse("emailAddress goes with type user or group, and only with them.");
1335
+ if ((type === "domain") !== (args["domain"] !== undefined))
1336
+ refuse("domain goes with type domain, and only with it.");
1337
+ if (person && args["allowFileDiscovery"] !== undefined)
1338
+ refuse("allowFileDiscovery applies to type domain or anyone only.");
1339
+ if (!person && args["sendNotificationEmail"] !== undefined)
1340
+ refuse("sendNotificationEmail applies to a user or group only.");
1341
+ const notify = person ? args["sendNotificationEmail"] === true : undefined;
1342
+ if (args["emailMessage"] !== undefined && notify !== true)
1343
+ refuse("emailMessage needs sendNotificationEmail true.");
1344
+ return projectPermission(await adding(() => client.json({
1345
+ method: "POST",
1346
+ path: `${filePath(args["fileId"])}/permissions`,
1347
+ query: {
1348
+ ...ALL_DRIVES,
1349
+ fields: PERMISSION_FIELDS,
1350
+ // Drive's default is to email; this connection's is not to.
1351
+ sendNotificationEmail: notify,
1352
+ emailMessage: args["emailMessage"],
1353
+ },
1354
+ body: compact({
1355
+ type,
1356
+ role: args["role"],
1357
+ emailAddress: args["emailAddress"],
1358
+ domain: args["domain"],
1359
+ allowFileDiscovery: person ? undefined : args["allowFileDiscovery"] === true,
1360
+ }),
1361
+ }, ctx), "Check list_permissions before sharing again: the share may already be in place, and its notification sent."));
1362
+ },
1363
+ },
1364
+ {
1365
+ name: "update_permission",
1366
+ description: "Change the role of one existing share on a Drive file or folder. Never makes anyone an owner; an inherited share is changed on the folder it comes from.",
1367
+ annotations: destructive,
1368
+ inputSchema: input({ fileId: FILE_ID, permissionId: PERMISSION_ID, role: ROLE_PROPERTY }, [
1369
+ "fileId",
1370
+ "permissionId",
1371
+ "role",
1372
+ ]),
1373
+ outputSchema: PERMISSION_SCHEMA,
1374
+ handler: async (args, ctx) => projectPermission(await client.json({
1375
+ method: "PATCH",
1376
+ path: `${filePath(args["fileId"])}/permissions/${encodeURIComponent(args["permissionId"])}`,
1377
+ query: { ...ALL_DRIVES, fields: PERMISSION_FIELDS },
1378
+ body: { role: args["role"] },
1379
+ }, ctx, IDEMPOTENT), String(args["permissionId"])),
1380
+ },
1381
+ {
1382
+ name: "delete_permission",
1383
+ description: "Remove one share from a Drive file or folder, revoking that user's, group's, domain's, or link's access. Never deletes the file; an owner cannot be removed.",
1384
+ annotations: destructive,
1385
+ inputSchema: input({ fileId: FILE_ID, permissionId: PERMISSION_ID }, ["fileId", "permissionId"]),
1386
+ outputSchema: {
1387
+ type: "object",
1388
+ properties: {
1389
+ fileId: { type: "string" },
1390
+ permissionId: { type: "string" },
1391
+ deleted: { type: "boolean", const: true },
1392
+ },
1393
+ required: ["fileId", "permissionId", "deleted"],
1394
+ },
1395
+ handler: async (args, ctx) => {
1396
+ await client.json({
1397
+ method: "DELETE",
1398
+ path: `${filePath(args["fileId"])}/permissions/${encodeURIComponent(args["permissionId"])}`,
1399
+ query: ALL_DRIVES,
1400
+ }, ctx);
1401
+ return { fileId: args["fileId"], permissionId: args["permissionId"], deleted: true };
1402
+ },
1403
+ },
1404
+ ];
1405
+ }
1406
+ /**
1407
+ * The last word on size, around every tool and every way it can return:
1408
+ * whatever a handler built, a result past the call's `maxBytes` — or the
1409
+ * shared budget, for a tool without one — is refused rather than delivered.
1410
+ * Each source is budgeted where it is built, so this should never fire; when
1411
+ * it does, the caller learns why instead of a result execute_code cannot
1412
+ * receive.
1413
+ *
1414
+ * A write handler returns only from Google's recorded 2xx to its write — the
1415
+ * shared client throws on every other status, a redirect included, and on a
1416
+ * reply it could not read — so a write that gets here has applied, and only
1417
+ * here does this connection say so, pointing at a re-read rather than a
1418
+ * repeat.
1419
+ */
1420
+ function guarded(list) {
1421
+ return list.map((tool) => ({
1422
+ ...tool,
1423
+ handler: async (args, ctx) => {
1424
+ const result = await tool.handler(args, ctx);
1425
+ const budget = typeof args?.["maxBytes"] === "number" ? args["maxBytes"] : DEFAULT_RESULT_BYTES;
1426
+ const bytes = jsonBytes(result);
1427
+ if (bytes <= budget)
1428
+ return result;
1429
+ const raisable = asRecord(asRecord(tool.inputSchema)["properties"])["maxBytes"] !== undefined;
1430
+ throw new ConnectorCallError("connector_call_failed", tool.annotations.readOnlyHint
1431
+ ? `This ${tool.name} result serializes to ${bytes} bytes, past ${raisable ? `this call's maxBytes of ${budget}` : `the ${budget} bytes one result may carry`}; nothing is returned. ${raisable ? `Raise maxBytes (up to ${MAX_RESULT_BYTES}, for a direct call_tool only), or narrow the request.` : "Read the item in Drive instead."}`
1432
+ : `${tool.name} applied, but its result serializes to ${bytes} bytes, past the ${budget} bytes one result may carry, so it is not returned. Re-read the item with get_file; do not repeat the write.`, { retryable: false });
1433
+ },
1434
+ }));
1435
+ }
1436
+ // --- Guide ------------------------------------------------------------------------
1437
+ function usageGuide(purpose, instructions) {
1438
+ const extra = instructions?.trim();
1439
+ return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
1440
+ }
1441
+ // --- Construction -----------------------------------------------------------------
1442
+ /**
1443
+ * A maintained Google Drive connection acting as each signed-in Workspace
1444
+ * user through a delegated service account.
1445
+ *
1446
+ * Setup, once per Workspace (shared with every Workspace provider):
1447
+ *
1448
+ * 1. In a Google Cloud project, enable the **Google Drive API**.
1449
+ * 2. Create a service account. Grant it no IAM roles: delegation, not project
1450
+ * permission, is what reaches users' files.
1451
+ * 3. Create a JSON key for it. If key creation is refused, the organization
1452
+ * policy `iam.disableServiceAccountKeyCreation` is enforced; an org policy
1453
+ * administrator can override it for this project alone.
1454
+ * 4. Copy the service account's numeric **client ID** (the key's `client_id`).
1455
+ * 5. As a Workspace super admin: Admin console → Security → Access and data
1456
+ * control → API controls → Manage Domain Wide Delegation → Add new (or edit
1457
+ * the client's existing entry, adding to its scopes). Paste the client ID
1458
+ * and exactly this scope ({@link DRIVE_SCOPES}):
1459
+ * `https://www.googleapis.com/auth/drive`.
1460
+ * A new grant can take up to 24 hours to apply.
1461
+ * 6. Configure inbound auth: a `subject` function is never asked about an
1462
+ * open deployment's anonymous requests, which fail `auth_required`.
1463
+ * Then give the deployment the key as a secret and map callers to accounts:
1464
+ *
1465
+ * ```ts
1466
+ * drive("files", {
1467
+ * purpose: "Staff documents and shared drives",
1468
+ * serviceAccount: env.GOOGLE_SERVICE_ACCOUNT_JSON,
1469
+ * subject: (identity) => accounts[identity.principal?.id ?? ""],
1470
+ * });
1471
+ * ```
1472
+ *
1473
+ * The `drive` scope reaches everything the user can; the tool surface is what
1474
+ * keeps this connection from deleting a file permanently or transferring
1475
+ * ownership. Every write crosses host approval through call_destructive_tool
1476
+ * or a trusted program.
1477
+ */
1478
+ export const drive = asProviderFactory({
1479
+ name: "drive",
1480
+ title: "Google Drive",
1481
+ kind: "api",
1482
+ readme: "Google Drive",
1483
+ bundle: {
1484
+ "baselineGzip": 28955,
1485
+ "maxGzip": 88955,
1486
+ "note": "./providers/drive starts at 28,955 B gzip (#680): the same class as ./providers/gmail — a hand-written api() surface over the shared Workspace delegation layer, with two confined transports (API and upload host) and no MCP SDK. The cap uses the existing baseline + 60,000 B policy.",
1487
+ },
1488
+ skill,
1489
+ options: GOOGLE_WORKSPACE_OPTIONS,
1490
+ create: driveConnector,
1491
+ });
1492
+ function driveConnector(id, options) {
1493
+ const connection = workspaceConnection("drive", options);
1494
+ const baseUrl = options.baseUrl?.trim() || DRIVE_API_BASE_URL;
1495
+ const shared = {
1496
+ provider: "Google Drive",
1497
+ api: "Google Drive API",
1498
+ scopes: DRIVE_SCOPES,
1499
+ maxResponseBytes: DRIVE_MAX_RESPONSE_BYTES,
1500
+ // Drive answers 404 for a file that exists but is not shared with this
1501
+ // account, exactly as for one that does not exist.
1502
+ notFound: "ambiguous",
1503
+ connection,
1504
+ };
1505
+ const client = googleWorkspaceClient({ ...shared, baseUrl });
1506
+ const upload = googleWorkspaceClient({ ...shared, baseUrl: uploadBaseUrl(baseUrl) });
1507
+ return api(id, {
1508
+ ...defined({
1509
+ callAdmission: options.callAdmission,
1510
+ maxResultBytes: options.maxResultBytes,
1511
+ }),
1512
+ title: options.title ?? "Google Drive",
1513
+ description: `Google Drive as the signed-in Workspace user: search, read, write, and share files, never delete them for good — ${connection.purpose}`,
1514
+ usageGuide: {
1515
+ content: usageGuide(connection.purpose, options.instructions),
1516
+ summary: "Each caller's own Drive: query syntax, Docs as Markdown, capped content, recoverable trash, quiet sharing.",
1517
+ // Required: whose Drive it is, the first-sheet CSV, and what
1518
+ // update_file_content replaces are conventions no schema can carry.
1519
+ required: true,
1520
+ },
1521
+ tools: guarded(tools(client, upload)),
1522
+ });
1523
+ }