@zackbart/connecta 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (332) hide show
  1. package/CHANGELOG.md +855 -5
  2. package/README.md +125 -116
  3. package/bin/connecta.mjs +121 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/argument-redaction.js +230 -0
  12. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  13. package/dist/auth/clerk-sdk/client.js +5 -0
  14. package/dist/auth/clerk-sdk/generated.js +1845 -0
  15. package/dist/auth/clerk-transport.js +19 -0
  16. package/dist/auth/clerk.d.ts +17 -7
  17. package/dist/auth/clerk.js +189 -107
  18. package/dist/auth/cloudflare-access.js +25 -26
  19. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  20. package/dist/auth/downstream-client-metadata.js +133 -0
  21. package/dist/auth/downstream-oauth.js +1584 -1580
  22. package/dist/auth/downstream-revocation.js +106 -0
  23. package/dist/auth/oauth-token-response.js +231 -0
  24. package/dist/auth/oauth-v2-migration.js +116 -0
  25. package/dist/auth/static-oauth.js +93 -71
  26. package/dist/auth-elicitation.js +288 -0
  27. package/dist/authorization-handoff.js +111 -0
  28. package/dist/branding.d.ts +8 -16
  29. package/dist/branding.js +26 -59
  30. package/dist/byte-read-response.js +39 -0
  31. package/dist/call-admission.js +12 -16
  32. package/dist/catalog-cache.js +739 -0
  33. package/dist/catalog-drift.d.ts +50 -15
  34. package/dist/catalog-drift.js +365 -128
  35. package/dist/catalog-fingerprint.js +2 -4
  36. package/dist/catalog-freshness.js +13 -0
  37. package/dist/catalog-limits.js +2 -2
  38. package/dist/catalog-service.js +409 -225
  39. package/dist/catalog.js +43 -84
  40. package/dist/concurrency.js +3 -4
  41. package/dist/config-defaults.d.ts +39 -0
  42. package/dist/config-defaults.js +46 -0
  43. package/dist/config-schema.d.ts +181 -0
  44. package/dist/config-schema.js +435 -0
  45. package/dist/config-value-sources.js +102 -0
  46. package/dist/config.d.ts +633 -0
  47. package/dist/config.js +508 -0
  48. package/dist/connector-access.d.ts +1 -0
  49. package/dist/connector-access.js +13 -15
  50. package/dist/connector-caller.d.ts +8 -0
  51. package/dist/connector-caller.js +15 -6
  52. package/dist/connector-scope.js +1 -1
  53. package/dist/connectors/api-connector.d.ts +59 -7
  54. package/dist/connectors/api-connector.js +106 -19
  55. package/dist/connectors/api.js +4 -1
  56. package/dist/connectors/guarded-fetch.d.ts +144 -0
  57. package/dist/connectors/guarded-fetch.js +87 -38
  58. package/dist/connectors/negotiation-cache.js +46 -0
  59. package/dist/connectors/option-shapes.js +27 -0
  60. package/dist/connectors/remote-mcp.d.ts +30 -4
  61. package/dist/connectors/remote-mcp.js +1774 -320
  62. package/dist/connectors/resource-uri.js +335 -0
  63. package/dist/credential-contract.d.ts +10 -0
  64. package/dist/credential-rules.js +1 -3
  65. package/dist/credential-url.js +108 -0
  66. package/dist/credentials.d.ts +6 -1
  67. package/dist/credentials.js +27 -18
  68. package/dist/d1.d.ts +130 -0
  69. package/dist/d1.js +290 -0
  70. package/dist/describe-config.d.ts +165 -0
  71. package/dist/describe-config.js +315 -0
  72. package/dist/described.js +106 -0
  73. package/dist/downstream-input-context.js +128 -0
  74. package/dist/downstream-input.js +325 -0
  75. package/dist/errors.d.ts +132 -31
  76. package/dist/errors.js +175 -87
  77. package/dist/execute.js +798 -318
  78. package/dist/executor-admission.d.ts +1 -0
  79. package/dist/executor-admission.js +11 -15
  80. package/dist/executor-contract.d.ts +20 -0
  81. package/dist/executor-contract.js +48 -0
  82. package/dist/executor-result.js +30 -13
  83. package/dist/executors/quickjs-child.js +15 -9
  84. package/dist/executors/quickjs-protocol.js +5 -6
  85. package/dist/executors/quickjs-runtime.js +152 -59
  86. package/dist/executors/quickjs.js +49 -39
  87. package/dist/favicon.js +1 -2
  88. package/dist/fix-prompt.js +1 -3
  89. package/dist/guest-runtime.js +238 -0
  90. package/dist/guest-types.d.ts +218 -0
  91. package/dist/guest-types.js +1 -0
  92. package/dist/html-security.js +24 -0
  93. package/dist/identity.js +1 -3
  94. package/dist/inbound-credential.js +52 -0
  95. package/dist/index.d.ts +24 -256
  96. package/dist/index.js +65 -478
  97. package/dist/invocation-auth.js +69 -0
  98. package/dist/invocation.js +262 -79
  99. package/dist/json-schema.d.ts +1 -1
  100. package/dist/mcp-client-context.js +35 -0
  101. package/dist/mcp-result.js +11 -7
  102. package/dist/meta-output.js +61 -0
  103. package/dist/meta-tool-names.d.ts +2 -0
  104. package/dist/meta-tool-names.js +3 -0
  105. package/dist/meta-tools.js +419 -272
  106. package/dist/module-contracts.d.ts +8 -23
  107. package/dist/node.d.ts +0 -2
  108. package/dist/node.js +2 -2
  109. package/dist/oauth-callback-outcome.js +1 -3
  110. package/dist/oauth-handoff.js +194 -0
  111. package/dist/oauth-sealing.js +20 -0
  112. package/dist/operator-record.d.ts +137 -0
  113. package/dist/operator-record.js +420 -0
  114. package/dist/operator-ui/assets.js +31 -0
  115. package/dist/operator-ui/contract.d.ts +72 -0
  116. package/dist/operator-ui/contract.js +1 -0
  117. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  118. package/dist/operator-ui/generated.js +5 -4
  119. package/dist/operator-ui/model.d.ts +12 -87
  120. package/dist/operator-ui/model.js +2 -9
  121. package/dist/operator-ui/setup-commands.js +1 -3
  122. package/dist/operator-ui/view.d.ts +323 -0
  123. package/dist/operator-ui/view.js +62 -189
  124. package/dist/page-styles.js +2 -3
  125. package/dist/program-result.js +5 -0
  126. package/dist/program-source.js +14 -2
  127. package/dist/{exempt-writes.js → program-writes.js} +60 -19
  128. package/dist/provider.d.ts +142 -0
  129. package/dist/provider.js +187 -0
  130. package/dist/providers/_shared/google/result-size.js +57 -0
  131. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  132. package/dist/providers/_shared/google/service-account.js +500 -0
  133. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  134. package/dist/providers/_shared/google/workspace.js +518 -0
  135. package/dist/providers/_shared/request-token.js +83 -0
  136. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  137. package/dist/providers/basecamp/index.js +997 -0
  138. package/dist/providers/basecamp/skill.generated.js +9 -0
  139. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  140. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  141. package/dist/providers/breeze/skill.generated.js +9 -0
  142. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  143. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  144. package/dist/providers/ccb/skill.generated.js +9 -0
  145. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  146. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  147. package/dist/providers/cloudflare/skill.generated.js +10 -0
  148. package/dist/providers/docs/index.d.ts +54 -0
  149. package/dist/providers/docs/index.js +1237 -0
  150. package/dist/providers/docs/skill.generated.js +9 -0
  151. package/dist/providers/drive/index.d.ts +53 -0
  152. package/dist/providers/drive/index.js +1523 -0
  153. package/dist/providers/drive/skill.generated.js +9 -0
  154. package/dist/providers/forms/index.d.ts +54 -0
  155. package/dist/providers/forms/index.js +1217 -0
  156. package/dist/providers/forms/skill.generated.js +11 -0
  157. package/dist/providers/github/auth.d.ts +32 -0
  158. package/dist/providers/github/auth.js +361 -0
  159. package/dist/providers/github/hosted.js +218 -0
  160. package/dist/providers/github/index.d.ts +13 -0
  161. package/dist/providers/github/index.js +140 -0
  162. package/dist/providers/github/key.js +100 -0
  163. package/dist/providers/github/rest.js +321 -0
  164. package/dist/providers/github/scope.d.ts +29 -0
  165. package/dist/providers/github/scope.js +124 -0
  166. package/dist/providers/github/skill.generated.js +8 -0
  167. package/dist/providers/gmail/index.d.ts +51 -0
  168. package/dist/providers/gmail/index.js +1436 -0
  169. package/dist/providers/gmail/skill.generated.js +9 -0
  170. package/dist/providers/infisical/index.d.ts +7 -0
  171. package/dist/providers/infisical/index.js +713 -0
  172. package/dist/providers/infisical/skill.generated.js +8 -0
  173. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  174. package/dist/providers/linear/index.js +352 -0
  175. package/dist/providers/linear/skill.generated.js +8 -0
  176. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  177. package/dist/providers/mixpanel/index.js +416 -0
  178. package/dist/providers/mixpanel/skill.generated.js +9 -0
  179. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  180. package/dist/providers/{notion.js → notion/index.js} +226 -239
  181. package/dist/providers/notion/skill.generated.js +10 -0
  182. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  183. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  184. package/dist/providers/overflow/skill.generated.js +8 -0
  185. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  186. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  187. package/dist/providers/planning-center/skill.generated.js +10 -0
  188. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  189. package/dist/providers/revenuecat/index.js +557 -0
  190. package/dist/providers/revenuecat/skill.generated.js +11 -0
  191. package/dist/providers/sheets/index.d.ts +54 -0
  192. package/dist/providers/sheets/index.js +1237 -0
  193. package/dist/providers/sheets/skill.generated.js +10 -0
  194. package/dist/providers/slides/index.d.ts +53 -0
  195. package/dist/providers/slides/index.js +2645 -0
  196. package/dist/providers/slides/skill.generated.js +9 -0
  197. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  198. package/dist/providers/stripe/index.js +266 -0
  199. package/dist/providers/stripe/skill.generated.js +11 -0
  200. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  201. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  202. package/dist/providers/tithely/skill.generated.js +8 -0
  203. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  204. package/dist/providers/vercel/index.js +1038 -0
  205. package/dist/providers/vercel/mcp-ownership.js +212 -0
  206. package/dist/providers/vercel/skill.generated.js +11 -0
  207. package/dist/registry.d.ts +110 -171
  208. package/dist/registry.js +588 -927
  209. package/dist/request-state.js +21 -0
  210. package/dist/resource-read.js +103 -0
  211. package/dist/result-shapes.js +17 -49
  212. package/dist/retired-toolkits.js +1 -1
  213. package/dist/routes/access-tokens.js +3 -6
  214. package/dist/routes/activity-context.js +21 -0
  215. package/dist/routes/activity-disclosure.js +40 -0
  216. package/dist/routes/activity.js +59 -21
  217. package/dist/routes/actor-labels.js +3 -50
  218. package/dist/routes/connect.js +221 -0
  219. package/dist/routes/credentials.js +15 -27
  220. package/dist/routes/mcp.js +365 -130
  221. package/dist/routes/oauth-client-metadata.js +15 -0
  222. package/dist/routes/oauth-management.js +45 -107
  223. package/dist/routes/oauth.js +110 -57
  224. package/dist/routes/operator.js +3 -3
  225. package/dist/routes/shared.d.ts +25 -64
  226. package/dist/routes/shared.js +160 -80
  227. package/dist/routes/skills.js +30 -0
  228. package/dist/routes/ui-config.js +225 -0
  229. package/dist/routes/ui-data.js +40 -35
  230. package/dist/routes/ui.js +66 -56
  231. package/dist/runtime/admission.js +4 -2
  232. package/dist/runtime/call-admission.js +3 -1
  233. package/dist/runtime/connector-scope.js +1 -4
  234. package/dist/runtime/run.js +10 -39
  235. package/dist/runtime/services.js +12 -117
  236. package/dist/runtime/shared-read.js +1 -3
  237. package/dist/runtime/storage.js +8 -1
  238. package/dist/sent-secrets.d.ts +93 -0
  239. package/dist/sent-secrets.js +1013 -0
  240. package/dist/server.js +28 -71
  241. package/dist/skills.js +416 -174
  242. package/dist/sqlite.d.ts +48 -0
  243. package/dist/sqlite.js +181 -0
  244. package/dist/storage/keys.js +443 -0
  245. package/dist/storage/memory.js +16 -4
  246. package/dist/storage/sql.d.ts +66 -0
  247. package/dist/storage/sql.js +543 -0
  248. package/dist/timeout.js +4 -2
  249. package/dist/tool-name.js +17 -0
  250. package/dist/tool-safety.d.ts +9 -35
  251. package/dist/tool-safety.js +20 -40
  252. package/dist/types.d.ts +336 -56
  253. package/dist/types.js +0 -1
  254. package/dist/ui.d.ts +7 -19
  255. package/dist/ui.js +76 -90
  256. package/dist/url-safety.js +3 -6
  257. package/dist/usage-guide.js +146 -0
  258. package/dist/validate.d.ts +7 -0
  259. package/dist/validate.js +309 -47
  260. package/dist/version.d.ts +1 -7
  261. package/dist/version.js +3 -7
  262. package/dist/worker.d.ts +11 -0
  263. package/dist/worker.js +435 -0
  264. package/documentation/README.md +63 -0
  265. package/documentation/architecture.md +696 -231
  266. package/documentation/auth.md +1069 -546
  267. package/documentation/code-mode.md +662 -356
  268. package/documentation/deploying.md +146 -0
  269. package/documentation/integrating.md +159 -0
  270. package/documentation/meta-tools.md +570 -216
  271. package/documentation/operating.md +135 -0
  272. package/documentation/operator-tests.md +97 -0
  273. package/documentation/operator-ui.md +132 -0
  274. package/documentation/provider-migration-0.29.md +65 -0
  275. package/examples/worker/AGENTS.md +34 -12
  276. package/examples/worker/README.md +293 -255
  277. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  278. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  279. package/examples/worker/src/connecta.config.ts +151 -0
  280. package/examples/worker/src/index.ts +14 -197
  281. package/examples/worker/wrangler.jsonc +15 -35
  282. package/package.json +126 -54
  283. package/templates/node/.dockerignore +1 -2
  284. package/templates/node/.env.example +20 -21
  285. package/templates/node/AGENTS.md +21 -17
  286. package/templates/node/Dockerfile +3 -5
  287. package/templates/node/README.md +140 -58
  288. package/templates/node/docker-compose.yml +11 -12
  289. package/templates/node/package.json +4 -2
  290. package/templates/node/src/connecta.config.ts +119 -0
  291. package/templates/node/src/index.ts +9 -154
  292. package/templates/node/src/provision-token.ts +17 -0
  293. package/AGENTS.md +0 -170
  294. package/dist/artifacts/connector.d.ts +0 -58
  295. package/dist/artifacts/connector.js +0 -730
  296. package/dist/artifacts/document.js +0 -86
  297. package/dist/artifacts/guide.js +0 -140
  298. package/dist/artifacts/html-scan.js +0 -329
  299. package/dist/artifacts/json.js +0 -78
  300. package/dist/artifacts/kv-store.d.ts +0 -20
  301. package/dist/artifacts/kv-store.js +0 -140
  302. package/dist/artifacts/markdown.js +0 -478
  303. package/dist/artifacts/module.d.ts +0 -37
  304. package/dist/artifacts/module.js +0 -66
  305. package/dist/artifacts/operations.d.ts +0 -197
  306. package/dist/artifacts/operations.js +0 -1002
  307. package/dist/artifacts/refresh.d.ts +0 -55
  308. package/dist/artifacts/refresh.js +0 -236
  309. package/dist/artifacts/routes.js +0 -206
  310. package/dist/artifacts/types.d.ts +0 -214
  311. package/dist/artifacts/types.js +0 -22
  312. package/dist/artifacts/validate.d.ts +0 -62
  313. package/dist/artifacts/validate.js +0 -651
  314. package/dist/artifacts.d.ts +0 -6
  315. package/dist/artifacts.js +0 -9
  316. package/dist/auth/bearer.d.ts +0 -11
  317. package/dist/auth/bearer.js +0 -50
  318. package/dist/providers/basecamp.js +0 -445
  319. package/dist/providers/linear.js +0 -217
  320. package/dist/providers/mixpanel.js +0 -247
  321. package/dist/providers/revenuecat.js +0 -317
  322. package/dist/providers/stripe.js +0 -253
  323. package/dist/providers/vercel.js +0 -1253
  324. package/dist/storage/file.d.ts +0 -15
  325. package/dist/storage/file.js +0 -455
  326. package/ethos.md +0 -93
  327. package/examples/worker/src/cloudflare-kv.ts +0 -43
  328. package/examples/worker/src/d1-activity-row.ts +0 -155
  329. package/examples/worker/src/d1-activity.ts +0 -147
  330. package/examples/worker/src/d1-storage.ts +0 -138
  331. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  332. package/templates/node/src/file-activity.ts +0 -182
@@ -1,12 +1,14 @@
1
+ import { withAuthorizationHandoff } from "./authorization-handoff.js";
2
+ import { carryCatalogFreshness, catalogIsFresh } from "./catalog-freshness.js";
3
+ import { hasControlCharacters } from "./tool-name.js";
1
4
  import { Effect, Result } from "effect";
2
5
  import { compactDiscoverySchema, compactDescriptionSchema, lexicalCorpusStatistics, lexicalQueryTerms, lexicalSearchQuery, matchesLexicalTerm, rankTools, schemaObjectKeys, summarizeDiscoveryDescription, summarizeDescription, typescriptSignature, } from "./catalog.js";
3
6
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
4
- import { boundedEchoText, classifyCallError, framingError, } from "./errors.js";
5
- import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName, } from "./skills.js";
7
+ import { boundedEchoText, classifyCallError, ConnectorCallError, framingError } from "./errors.js";
8
+ import { connectorGuide, connectorGuideRequired, connectorGuideSummary, connectorSkillName } from "./skills.js";
6
9
  import { runEdge, withDeadlineEffect } from "./runtime/run.js";
7
10
  import { SharedRead } from "./runtime/shared-read.js";
8
- import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, } from "./timeout.js";
9
- import { isApprovalExempt, isExplicitlyReadOnly, NO_EXEMPTIONS, } from "./tool-safety.js";
11
+ import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
10
12
  export const DEFAULT_SEARCH_LIMIT = 8;
11
13
  export const MAX_SEARCH_LIMIT = 100;
12
14
  export const MAX_DESCRIBE_ADDRESSES = 100;
@@ -20,6 +22,49 @@ const MAX_QUERY_TERM_LENGTH = 64;
20
22
  */
21
23
  const MAX_IDENTITY_CONNECTORS = 3;
22
24
  const MAX_DESCRIBE_SUGGESTIONS = 3;
25
+ // Recognized service names are query vocabulary, never a deployment inventory.
26
+ // Unknown/custom services use the explicit connector scope. Keep this independent
27
+ // of provider implementations so discovery cannot pull them into root imports.
28
+ const SERVICE_NAMES = [
29
+ "GitHub",
30
+ "GitLab",
31
+ "Supabase",
32
+ "PostHog",
33
+ "Linear",
34
+ "Mixpanel",
35
+ "RevenueCat",
36
+ "Stripe",
37
+ "Notion",
38
+ "Slack",
39
+ "Vercel",
40
+ "Cloudflare",
41
+ "Basecamp",
42
+ "Breeze",
43
+ "Tithely",
44
+ "Planning Center",
45
+ "Google Drive",
46
+ "Google Docs",
47
+ "Google Sheets",
48
+ "Google Slides",
49
+ "Gmail",
50
+ ];
51
+ function normalizedPhrase(text) {
52
+ return lexicalQueryTerms(text.toLowerCase()).join(" ");
53
+ }
54
+ function containsPhrase(query, phrase) {
55
+ const normalized = normalizedPhrase(phrase);
56
+ return normalized.length > 0 && ` ${normalizedPhrase(query)} `.includes(` ${normalized} `);
57
+ }
58
+ function connectorNames(connector) {
59
+ return [connector.id, ...(connector.title ? [connector.title] : [])];
60
+ }
61
+ function connectorMatchRank(query, connector) {
62
+ if (connectorNames(connector).some((name) => containsPhrase(query, name)))
63
+ return 2;
64
+ return SERVICE_NAMES.some((service) => containsPhrase(query, service) && connectorNames(connector).some((name) => containsPhrase(name, service)))
65
+ ? 1
66
+ : 0;
67
+ }
23
68
  const encoder = new TextEncoder();
24
69
  /** Clip one echoed query term without splitting a non-BMP code point. */
25
70
  function boundedQueryTerm(term) {
@@ -47,10 +92,7 @@ export class DiscoveryPolicyError extends Error {
47
92
  function discoverySearchLimit(value) {
48
93
  if (value === undefined)
49
94
  return DEFAULT_SEARCH_LIMIT;
50
- if (typeof value !== "number" ||
51
- !Number.isInteger(value) ||
52
- value < 1 ||
53
- value > MAX_SEARCH_LIMIT) {
95
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_SEARCH_LIMIT) {
54
96
  throw new DiscoveryPolicyError("invalid_args", `limit must be a whole number from 1 through ${MAX_SEARCH_LIMIT}. Page through larger catalogs with offset.`);
55
97
  }
56
98
  return value;
@@ -60,11 +102,7 @@ function discoveryAddresses(args) {
60
102
  if (args.address !== undefined && args.addresses !== undefined) {
61
103
  throw new DiscoveryPolicyError("invalid_args", "describe takes either address or addresses, not both.");
62
104
  }
63
- const value = args.address !== undefined
64
- ? typeof args.address === "string"
65
- ? [args.address]
66
- : undefined
67
- : args.addresses;
105
+ const value = args.address !== undefined ? (typeof args.address === "string" ? [args.address] : undefined) : args.addresses;
68
106
  if (!Array.isArray(value)) {
69
107
  throw new DiscoveryPolicyError("invalid_args", 'describe takes { address: "<connectorId>.<toolName>" } or { addresses: ["<connectorId>.<toolName>", ...] }.');
70
108
  }
@@ -89,8 +127,7 @@ function editDistance(left, right) {
89
127
  for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
90
128
  const current = [leftIndex + 1];
91
129
  for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
92
- current.push(Math.min(previous[rightIndex + 1] + 1, current[rightIndex] + 1, previous[rightIndex] +
93
- (left[leftIndex] === right[rightIndex] ? 0 : 1)));
130
+ current.push(Math.min(previous[rightIndex + 1] + 1, current[rightIndex] + 1, previous[rightIndex] + (left[leftIndex] === right[rightIndex] ? 0 : 1)));
94
131
  }
95
132
  previous = current;
96
133
  }
@@ -99,7 +136,7 @@ function editDistance(left, right) {
99
136
  /** Nearby names only; descriptions never influence describe-miss recovery. */
100
137
  function describeSuggestions(connectorId, attemptedName, tools) {
101
138
  const attempted = attemptedName.toLowerCase();
102
- return tools
139
+ return (tools
103
140
  .map((tool, order) => {
104
141
  const name = tool.name.toLowerCase();
105
142
  return { tool, order, distance: editDistance(attempted, name) };
@@ -115,7 +152,7 @@ function describeSuggestions(connectorId, attemptedName, tools) {
115
152
  // A clipped address would no longer be canonical. Omit an implausibly
116
153
  // large catalog name instead of letting one suggestion erase the page.
117
154
  .filter((address) => boundedEchoText(address) === address)
118
- .slice(0, MAX_DESCRIBE_SUGGESTIONS);
155
+ .slice(0, MAX_DESCRIBE_SUGGESTIONS));
119
156
  }
120
157
  /** Serialize once and count the exact bytes the MCP adapter would emit. */
121
158
  export function boundedDiscoveryText(value, hint) {
@@ -132,9 +169,7 @@ export function boundedDiscoveryText(value, hint) {
132
169
  function discoverySafety(value) {
133
170
  if (value === undefined)
134
171
  return "all";
135
- if (value !== "readOnly" &&
136
- value !== "approvalRequired" &&
137
- value !== "all") {
172
+ if (value !== "readOnly" && value !== "approvalRequired" && value !== "all") {
138
173
  throw new DiscoveryPolicyError("invalid_args", 'safety must be "readOnly", "approvalRequired", or "all".');
139
174
  }
140
175
  return value;
@@ -142,9 +177,7 @@ function discoverySafety(value) {
142
177
  function toolsForSafety(tools, safety) {
143
178
  if (safety === "all")
144
179
  return tools;
145
- return tools.filter((tool) => safety === "readOnly"
146
- ? isExplicitlyReadOnly(tool)
147
- : !isExplicitlyReadOnly(tool));
180
+ return tools.filter((tool) => safety === "readOnly" ? tool.classification === "read" : tool.classification !== "read");
148
181
  }
149
182
  /**
150
183
  * Reasons discovery can determine without reading arguments or guessing at a
@@ -157,7 +190,7 @@ function guideRequiredReasons(connector, tool, schemaTruncated) {
157
190
  const reasons = [];
158
191
  if (connectorGuideRequired(connector))
159
192
  reasons.push("connector_required");
160
- if (!isExplicitlyReadOnly(tool))
193
+ if (tool.classification !== "read")
161
194
  reasons.push("approval_required");
162
195
  if (schemaTruncated)
163
196
  reasons.push("schema_truncated");
@@ -178,11 +211,152 @@ function schemaKeyMetadata(input, output) {
178
211
  requiredInputKeys: inputKeys.required,
179
212
  }
180
213
  : {}),
181
- ...(outputKeys && outputKeys.properties.length > 0
182
- ? { outputKeys: outputKeys.properties }
183
- : {}),
214
+ ...(outputKeys && outputKeys.properties.length > 0 ? { outputKeys: outputKeys.properties } : {}),
184
215
  };
185
216
  }
217
+ const stringListSchema = { type: "array", items: { type: "string" } };
218
+ const guideProperties = {
219
+ guide: { type: "string" },
220
+ guideSummary: { type: "string" },
221
+ guideRequired: { const: true },
222
+ guideRequiredReasons: {
223
+ type: "array",
224
+ items: { enum: ["connector_required", "approval_required", "schema_truncated"] },
225
+ },
226
+ };
227
+ const catalogFailureProperties = {
228
+ code: { type: "string" },
229
+ message: { type: "string" },
230
+ retryable: { type: "boolean" },
231
+ retryAfterMs: { type: "integer", minimum: 0 },
232
+ connector: { type: "string" },
233
+ recovery: { enum: ["oauth", "operator_config", "unavailable"] },
234
+ status: { const: "auth_required" },
235
+ authorizationUrl: { type: "string" },
236
+ operatorUrl: { type: "string" },
237
+ instructions: { type: "string" },
238
+ credential: {
239
+ type: "object",
240
+ required: ["label", "fields"],
241
+ additionalProperties: false,
242
+ properties: {
243
+ label: { type: "string" },
244
+ fields: {
245
+ type: "array",
246
+ items: {
247
+ type: "object",
248
+ required: ["name"],
249
+ additionalProperties: false,
250
+ properties: { name: { type: "string" }, guidance: { type: "string" } },
251
+ },
252
+ },
253
+ },
254
+ },
255
+ retry: { type: "string" },
256
+ nextAction: {
257
+ type: "object",
258
+ required: ["tool", "arguments", "operatorHandoff"],
259
+ additionalProperties: false,
260
+ properties: {
261
+ tool: { const: "authorize_connector" },
262
+ arguments: {
263
+ type: "object",
264
+ required: ["connector"],
265
+ additionalProperties: false,
266
+ properties: { connector: { type: "string" } },
267
+ },
268
+ operatorHandoff: { type: "string" },
269
+ },
270
+ },
271
+ };
272
+ /**
273
+ * The outputSchema contract for either search adapter. Guest API registration
274
+ * can reuse this value instead of maintaining a second page definition.
275
+ */
276
+ export const CATALOG_SEARCH_RESULT_SCHEMA = {
277
+ type: "object",
278
+ required: ["catalogErrors", "tools", "total", "offset", "limit", "hasMore"],
279
+ additionalProperties: false,
280
+ properties: {
281
+ catalogErrors: {
282
+ type: "array",
283
+ items: {
284
+ type: "object",
285
+ required: ["connector", "code", "message", "retryable"],
286
+ additionalProperties: false,
287
+ properties: {
288
+ ...catalogFailureProperties,
289
+ },
290
+ },
291
+ },
292
+ absence: {
293
+ type: "object",
294
+ required: ["service", "message", "configuredConnectors"],
295
+ additionalProperties: false,
296
+ properties: {
297
+ service: { type: "string" },
298
+ message: { type: "string" },
299
+ configuredConnectors: stringListSchema,
300
+ },
301
+ },
302
+ tools: {
303
+ type: "array",
304
+ items: {
305
+ type: "object",
306
+ required: ["name", "address"],
307
+ additionalProperties: false,
308
+ properties: {
309
+ name: { type: "string" },
310
+ address: { type: "string" },
311
+ connectorTitle: { type: "string" },
312
+ description: { type: "string" },
313
+ inputSchema: {},
314
+ outputSchema: {},
315
+ signature: { type: "string" },
316
+ schemaFormat: { enum: ["json", "text"] },
317
+ outputSchemaSource: { const: "observed" },
318
+ inputSchemaTruncated: { const: true },
319
+ outputSchemaTruncated: { const: true },
320
+ inputKeys: stringListSchema,
321
+ requiredInputKeys: stringListSchema,
322
+ outputKeys: stringListSchema,
323
+ annotations: { type: "object" },
324
+ classification: { enum: ["read", "write"] },
325
+ ...guideProperties,
326
+ },
327
+ },
328
+ },
329
+ total: { type: "integer", minimum: 0 },
330
+ offset: { type: "integer", minimum: 0 },
331
+ limit: { type: "integer", minimum: 1, maximum: MAX_SEARCH_LIMIT },
332
+ hasMore: { type: "boolean" },
333
+ nextOffset: { type: "integer", minimum: 0 },
334
+ matchMode: { const: "partial" },
335
+ queryAnalysis: {
336
+ type: "object",
337
+ required: ["representedTerms", "otherResultTerms", "unmatchedTerms"],
338
+ additionalProperties: false,
339
+ properties: {
340
+ representedTerms: stringListSchema,
341
+ otherResultTerms: stringListSchema,
342
+ unmatchedTerms: stringListSchema,
343
+ truncated: { const: true },
344
+ connectorScope: { type: "string" },
345
+ unknownConnector: { const: true },
346
+ configuredConnectors: stringListSchema,
347
+ unavailableConnectorCount: { type: "integer", minimum: 0 },
348
+ catalogError: {
349
+ type: "object",
350
+ required: ["code", "message", "retryable"],
351
+ additionalProperties: false,
352
+ properties: catalogFailureProperties,
353
+ },
354
+ ...guideProperties,
355
+ guidance: { type: "string" },
356
+ },
357
+ },
358
+ },
359
+ };
186
360
  function renderSearchSchema(schema, format) {
187
361
  if (format === "json")
188
362
  return { schema, truncated: false };
@@ -198,16 +372,17 @@ export class CatalogService {
198
372
  registry;
199
373
  baseUrl;
200
374
  requestScope;
375
+ defer;
201
376
  probeTimeoutMs;
202
377
  concurrency;
203
378
  searchRoute;
204
- readOptions;
205
379
  requestSignal;
206
- approval;
207
380
  // The request-scoped catalog cache: one shared read per connector asked
208
381
  // about, started by the first asker and joined by every later one. A
209
382
  // success stays for the rest of the request, so a search and the call after
210
- // it see one catalog; a failure, or a read cancelled because every asker
383
+ // it see one catalog. The cache owns a deep copy and each asker receives
384
+ // another, so discovery, invocation, and guest code never hold its objects.
385
+ // A failure, or a read cancelled because every asker
211
386
  // left it, is dropped so the next ask reads again. The service lives and
212
387
  // dies with one request, so no read here is ever awaited from another.
213
388
  //
@@ -220,19 +395,12 @@ export class CatalogService {
220
395
  constructor(registry, baseUrl, options = {}) {
221
396
  this.registry = registry;
222
397
  this.baseUrl = baseUrl;
223
- this.approval = options.approval ?? NO_EXEMPTIONS;
224
398
  this.requestScope = options.requestScope ?? {};
225
- this.probeTimeoutMs =
226
- normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
399
+ this.probeTimeoutMs = normalizeTimeoutMs(options.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
227
400
  this.concurrency = resolveDiscoveryConcurrency(options.concurrency);
228
401
  this.searchRoute = options.searchRoute ?? "search_tools";
229
402
  this.requestSignal = options.requestSignal;
230
- this.readOptions = options.defer
231
- ? {
232
- defer: options.defer,
233
- refreshTimeoutMs: this.probeTimeoutMs,
234
- }
235
- : undefined;
403
+ this.defer = options.defer;
236
404
  }
237
405
  /**
238
406
  * Send a caller back to discovery through the surface it can actually reach.
@@ -269,7 +437,13 @@ export class CatalogService {
269
437
  return Effect.suspend(() => {
270
438
  let read = this.catalogs.get(id);
271
439
  if (!read) {
272
- const started = new SharedRead((signal) => this.registry.getTools(id, this.baseUrl, this.requestScope, { signal, timeoutMs: this.probeTimeoutMs }, this.readOptions), (succeeded) => {
440
+ const started = new SharedRead((signal) => this.registry
441
+ .getTools(id, this.baseUrl, this.requestScope, {
442
+ signal,
443
+ timeoutMs: this.probeTimeoutMs,
444
+ ...(this.defer ? { defer: this.defer } : {}),
445
+ })
446
+ .then((tools) => carryCatalogFreshness(tools, structuredClone(tools))), (succeeded) => {
273
447
  // Evicted before the read resumes anyone, so an asker that
274
448
  // retries on hearing of the failure starts a fresh read.
275
449
  if (!succeeded && this.catalogs.get(id) === started) {
@@ -279,7 +453,7 @@ export class CatalogService {
279
453
  this.catalogs.set(id, started);
280
454
  read = started;
281
455
  }
282
- return read.join(callOptions.signal);
456
+ return Effect.map(read.join(callOptions.signal), (tools) => carryCatalogFreshness(tools, structuredClone(tools)));
283
457
  });
284
458
  }
285
459
  // Every named catalog under its own probe deadline, `concurrency` at a time,
@@ -291,22 +465,21 @@ export class CatalogService {
291
465
  return Effect.forEach(ids, (id) => Effect.result(withDeadlineEffect((signal) => this.catalog(id, { signal }), {
292
466
  ...(this.requestSignal ? { signal: this.requestSignal } : {}),
293
467
  timeoutMs: this.probeTimeoutMs,
294
- timeoutError: new Error(`${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`),
468
+ timeoutError: new ConnectorCallError("timeout", `${route} probe of "${id}" timed out after ${this.probeTimeoutMs}ms`),
295
469
  })), { concurrency: this.concurrency });
296
470
  }
297
471
  outputSchema(connectorId, tool) {
298
472
  if (tool.outputSchema)
299
473
  return { schema: tool.outputSchema };
300
474
  const observed = this.registry.observedOutputSchema(connectorId, tool);
301
- return observed
302
- ? { schema: observed, source: "observed" }
303
- : {};
475
+ return observed ? { schema: observed, source: "observed" } : {};
304
476
  }
305
477
  unknownAddressFailure(address, query) {
306
478
  return {
307
479
  ok: false,
308
480
  error: {
309
481
  ...framingError("unknown_address", `Unknown address "${boundedEchoText(address)}"`),
482
+ configuredConnectors: this.registry.listConnectors().map((connector) => connector.id),
310
483
  nextAction: this.searchRecovery({ query: recoveryQuery(query) }, "Find the configured canonical address before retrying."),
311
484
  },
312
485
  catalogMs: 0,
@@ -326,8 +499,10 @@ export class CatalogService {
326
499
  return {
327
500
  ok: false,
328
501
  error: {
329
- ...framingError("unknown_tool", `Unknown tool "${boundedEchoText(toolName)}" on connector "${connector.id}"`),
330
- nextAction: this.searchRecovery({ query: recoveryQuery(toolName), connector: connector.id }, "Find the connector's current canonical tool address."),
502
+ ...framingError("unknown_tool", hasControlCharacters(toolName)
503
+ ? `Unknown tool on connector "${connector.id}"`
504
+ : `Unknown tool "${boundedEchoText(toolName)}" on connector "${connector.id}"`),
505
+ nextAction: this.searchRecovery({ query: hasControlCharacters(toolName) ? "" : recoveryQuery(toolName), connector: connector.id }, "Find the connector's current canonical tool address."),
331
506
  },
332
507
  catalogMs: Date.now() - started,
333
508
  connector,
@@ -357,19 +532,56 @@ export class CatalogService {
357
532
  }
358
533
  return {
359
534
  ok: true,
360
- resolved: { connector, toolName, definition },
535
+ resolved: { connector, toolName, definition, classificationFresh: catalogIsFresh(tools) },
361
536
  catalogMs: Date.now() - started,
362
537
  };
363
538
  },
364
539
  });
365
540
  });
366
541
  }
542
+ async catalogFailure(connector, failure) {
543
+ const classified = classifyCallError(failure, "catalog_lookup_failed");
544
+ const error = {
545
+ connector: connector.id,
546
+ code: classified.code,
547
+ // Catalog discovery reports recovery facts, never connector-authored
548
+ // error text. Clipping a downstream message does not make it safe.
549
+ message: classified.code === "downstream_oauth_required"
550
+ ? `Connector "${connector.id}" requires downstream OAuth authorization. When an auth error includes a handoff, give it to the user or operator.`
551
+ : classified.code === "auth_required"
552
+ ? `Connector "${connector.id}" requires operator-managed credentials or configuration. When an auth error includes a handoff, give it to the user or operator.`
553
+ : classified.code === "provider_permission_denied"
554
+ ? `Connector "${connector.id}" requires permission from the provider's resource owner or administrator. Reconnecting alone will not grant access.`
555
+ : `Connector "${connector.id}" catalog lookup failed (${classified.code}).`,
556
+ retryable: classified.retryable,
557
+ ...(classified.retryAfterMs === undefined ? {} : { retryAfterMs: classified.retryAfterMs }),
558
+ };
559
+ if (error.code === "auth_required" || error.code === "downstream_oauth_required") {
560
+ error.recovery = connector.startAuth
561
+ ? "oauth"
562
+ : this.registry.credentialUiAvailable() &&
563
+ connector.credential &&
564
+ this.registry.contextFor(connector.id, this.baseUrl, this.requestScope).credential
565
+ ? "operator_config"
566
+ : "unavailable";
567
+ error.nextAction = {
568
+ tool: "authorize_connector",
569
+ arguments: { connector: connector.id },
570
+ operatorHandoff: "Give the URL and instructions it returns to the operator.",
571
+ };
572
+ error.retry = `Retry discovery after the operator completes recovery for "${connector.id}".`;
573
+ }
574
+ else if (error.code === "provider_permission_denied") {
575
+ error.retry =
576
+ "Ask the provider's resource owner or administrator to grant the required permission or scope, then retry. Reconnecting alone will not grant access.";
577
+ }
578
+ return withAuthorizationHandoff(this.requestScope, error);
579
+ }
367
580
  async search(args) {
368
581
  if (args.query !== undefined && typeof args.query !== "string") {
369
582
  throw new DiscoveryPolicyError("invalid_args", "query must be a string. Omit it or use an empty string to browse the catalog.");
370
583
  }
371
- if (args.connector !== undefined &&
372
- boundedEchoText(args.connector) !== args.connector) {
584
+ if (args.connector !== undefined && boundedEchoText(args.connector) !== args.connector) {
373
585
  // The scope is echoed back as `queryAnalysis.connectorScope`; a clipped
374
586
  // copy could name a different connector, so refuse instead of clamping.
375
587
  throw new DiscoveryPolicyError("invalid_args", "connector must be at most 512 UTF-8 bytes.");
@@ -378,38 +590,44 @@ export class CatalogService {
378
590
  const retrievalQuery = lexicalSearchQuery(query);
379
591
  const safety = discoverySafety(args.safety);
380
592
  const limit = discoverySearchLimit(args.limit);
381
- if (args.offset !== undefined && (typeof args.offset !== "number" ||
382
- !Number.isInteger(args.offset) ||
383
- args.offset < 0)) {
593
+ if (args.offset !== undefined &&
594
+ (typeof args.offset !== "number" || !Number.isInteger(args.offset) || args.offset < 0)) {
384
595
  throw new DiscoveryPolicyError("invalid_args", "offset must be a non-negative whole number. Start at 0 or use the previous page's nextOffset.");
385
596
  }
386
597
  const offset = args.offset ?? 0;
387
- const scopedConnector = args.connector
388
- ? this.registry.getConnector(args.connector)
598
+ const scopedConnector = args.connector ? this.registry.getConnector(args.connector) : undefined;
599
+ const visibleConnectors = this.registry.listConnectors();
600
+ const namedService = !args.connector ? SERVICE_NAMES.find((name) => containsPhrase(query, name)) : undefined;
601
+ const absentService = args.connector && !scopedConnector
602
+ ? args.connector
603
+ : namedService &&
604
+ !visibleConnectors.some((connector) => connectorNames(connector).some((name) => containsPhrase(name, namedService)))
605
+ ? namedService
606
+ : undefined;
607
+ const absence = absentService
608
+ ? {
609
+ service: absentService,
610
+ message: `No connector for "${absentService}" is configured for this endpoint.`,
611
+ configuredConnectors: visibleConnectors.map((connector) => connector.id),
612
+ }
389
613
  : undefined;
390
- const connectors = args.connector
391
- ? scopedConnector
392
- ? [scopedConnector]
393
- : []
394
- : this.registry.listConnectors();
395
- // Named for this service's route: a scoped search echoes a timed-out
396
- // probe's message back as `queryAnalysis.catalogError`, and a program
397
- // cannot call search_tools.
614
+ const connectors = absence ? [] : args.connector ? (scopedConnector ? [scopedConnector] : []) : visibleConnectors;
615
+ // Timeout messages name the route the caller used.
398
616
  const catalogs = await runEdge(this.discoveryCatalogs(connectors.map((connector) => connector.id), this.searchRoute));
399
617
  const searchableCatalogs = catalogs.map((catalog) => Result.map(catalog, (tools) => toolsForSafety(tools, safety)));
400
618
  const matches = [];
401
619
  let matchMode = "all";
402
- const statistics = lexicalCorpusStatistics(searchableCatalogs.flatMap((catalog) => Result.isSuccess(catalog) ? [catalog.success] : []), retrievalQuery);
620
+ const statistics = lexicalCorpusStatistics(searchableCatalogs.flatMap((catalog) => (Result.isSuccess(catalog) ? [catalog.success] : [])), retrievalQuery);
403
621
  const trimmedQuery = query.trim();
404
622
  const isBrowse = trimmedQuery.length === 0;
405
623
  const queryTerms = lexicalQueryTerms(retrievalQuery);
624
+ const rawPhrase = lexicalQueryTerms(query).join(" ");
406
625
  const queryTermCount = queryTerms.length;
407
626
  const unsearchableQuery = !isBrowse && queryTermCount === 0;
408
627
  const analysisTerms = unsearchableQuery ? [trimmedQuery] : queryTerms;
409
628
  const analyzedTerms = analysisTerms.slice(0, MAX_QUERY_TERMS);
410
629
  const displayTerm = (term) => boundedQueryTerm(term).text;
411
- const queryMetadataTruncated = analysisTerms.length > analyzedTerms.length ||
412
- analyzedTerms.some((term) => boundedQueryTerm(term).truncated);
630
+ const queryMetadataTruncated = analysisTerms.length > analyzedTerms.length || analyzedTerms.some((term) => boundedQueryTerm(term).truncated);
413
631
  const collectMatches = (mode) => {
414
632
  const collected = [];
415
633
  let orderBase = 0;
@@ -419,15 +637,43 @@ export class CatalogService {
419
637
  throw new Error("Catalog result has no corresponding connector");
420
638
  }
421
639
  if (Result.isSuccess(catalog)) {
422
- for (const ranked of rankTools(catalog.success, retrievalQuery, mode, statistics, query)) {
640
+ const connectorRank = args.connector ? 0 : connectorMatchRank(query, connector);
641
+ const exactConnector = connectorRank > 0;
642
+ const connectorTerms = new Set([
643
+ ...connectorNames(connector).flatMap(lexicalQueryTerms),
644
+ ...(namedService && connectorNames(connector).some((name) => containsPhrase(name, namedService))
645
+ ? lexicalQueryTerms(namedService)
646
+ : []),
647
+ ]);
648
+ const localStatistics = exactConnector
649
+ ? lexicalCorpusStatistics([catalog.success], retrievalQuery)
650
+ : undefined;
651
+ // An exact identity is a browse even when one tool repeats that
652
+ // identity in its name or description. Action queries still keep
653
+ // identity terms that also name an actual capability.
654
+ const identityBrowse = connectorNames(connector).some((name) => normalizedPhrase(query) === normalizedPhrase(name));
655
+ const toolQuery = identityBrowse
656
+ ? ""
657
+ : exactConnector
658
+ ? queryTerms
659
+ .filter((term) => !connectorTerms.has(term) ||
660
+ (localStatistics?.nameMatches.get(term)?.size ?? 0) > 0 ||
661
+ (localStatistics?.descriptionMatches.get(term)?.size ?? 0) > 0)
662
+ .join(" ")
663
+ : retrievalQuery;
664
+ const toolTermCount = lexicalQueryTerms(toolQuery).length;
665
+ for (const ranked of rankTools(catalog.success, toolQuery, mode, statistics, query)) {
423
666
  collected.push({
424
667
  connector,
425
668
  tool: ranked.tool,
426
669
  score: ranked.score,
427
670
  order: orderBase + ranked.order,
428
671
  exactName: ranked.exactName,
672
+ exactLookup: rawPhrase === lexicalQueryTerms(ranked.tool.name).join(" ") ||
673
+ rawPhrase === lexicalQueryTerms(`${connector.id}.${ranked.tool.name}`).join(" "),
674
+ connectorRank,
429
675
  matchedTermCount: ranked.matchedTermCount,
430
- complete: isBrowse || ranked.matchedTermCount === queryTermCount,
676
+ complete: isBrowse || ranked.matchedTermCount === toolTermCount,
431
677
  });
432
678
  }
433
679
  }
@@ -438,34 +684,33 @@ export class CatalogService {
438
684
  // A non-empty query that normalizes to no lexical terms is not a browse.
439
685
  // Ranking an empty phrase would otherwise return every tool as an
440
686
  // unrelated zero-score match, with no coverage to explain the result.
441
- const rankedMatches = unsearchableQuery
442
- ? []
443
- : collectMatches(isBrowse ? "all" : "partial");
687
+ const rankedMatches = unsearchableQuery ? [] : collectMatches(isBrowse ? "all" : "partial");
444
688
  const completeMatchCount = rankedMatches.filter((match) => match.complete).length;
445
689
  matches.push(...rankedMatches.filter((match) => match.complete ||
446
690
  completeMatchCount === 0 ||
447
691
  match.exactName ||
692
+ match.connectorRank > 0 ||
448
693
  match.matchedTermCount >= 2));
449
694
  if (!isBrowse && !unsearchableQuery && completeMatchCount === 0) {
450
695
  matchMode = "partial";
451
696
  }
452
- // Complete matches and exact tool-name phrases share the first rank tier.
453
- // This lets a strong action/object name beat a weak description-only
454
- // decoy. Other partial matches fill the remaining page only after every
455
- // complete match, regardless of a rare-term score spike.
697
+ // Exact tool names precede connector identity and lexical scoring. A
698
+ // longer name such as get_issue_status cannot beat an exact get_issue
699
+ // merely because its description repeats rare query terms.
456
700
  matches.sort((a, b) => {
457
701
  const aFirstTier = a.complete || a.exactName;
458
702
  const bFirstTier = b.complete || b.exactName;
459
- return (Number(bFirstTier) - Number(aFirstTier) ||
703
+ return (Number(b.exactLookup) - Number(a.exactLookup) ||
704
+ Number(b.exactName) - Number(a.exactName) ||
705
+ b.connectorRank - a.connectorRank ||
706
+ Number(bFirstTier) - Number(aFirstTier) ||
460
707
  b.score - a.score ||
461
708
  a.order - b.order);
462
709
  });
463
710
  const pageMatches = matches.slice(offset, offset + limit);
464
711
  const withSchemas = args.includeSchemas;
465
712
  const entries = pageMatches.map((match) => {
466
- const output = withSchemas
467
- ? this.outputSchema(match.connector.id, match.tool)
468
- : {};
713
+ const output = withSchemas ? this.outputSchema(match.connector.id, match.tool) : {};
469
714
  const input = match.tool.inputSchema ?? { type: "object" };
470
715
  const signature = withSchemas === "typescript"
471
716
  ? typescriptSignature(input, output.schema, {
@@ -484,9 +729,7 @@ export class CatalogService {
484
729
  : schemaFormat && output.schema
485
730
  ? renderSearchSchema(output.schema, schemaFormat)
486
731
  : undefined;
487
- const schemaKeys = withSchemas && args.includeSchemaKeys
488
- ? schemaKeyMetadata(input, output.schema)
489
- : undefined;
732
+ const schemaKeys = withSchemas && args.includeSchemaKeys ? schemaKeyMetadata(input, output.schema) : undefined;
490
733
  const description = summarizeDiscoveryDescription(match.tool.description, args.fullDescriptions === true);
491
734
  const requiredReasons = guideRequiredReasons(match.connector, match.tool, renderedInput?.truncated === true || renderedOutput?.truncated === true);
492
735
  const guideSummary = connectorGuideSummary(match.connector);
@@ -507,42 +750,25 @@ export class CatalogService {
507
750
  inputSchema: renderedInput?.schema,
508
751
  }
509
752
  : {}),
753
+ ...(withSchemas ? { schemaFormat: withSchemas === "json" ? "json" : "text" } : {}),
510
754
  ...(signature ? { signature: signature.text } : {}),
511
- ...(renderedInput?.truncated
512
- ? { inputSchemaTruncated: true }
513
- : {}),
755
+ ...(renderedInput?.truncated ? { inputSchemaTruncated: true } : {}),
514
756
  ...(schemaFormat && output.schema
515
757
  ? {
516
758
  outputSchema: renderedOutput?.schema,
517
759
  }
518
760
  : {}),
519
- ...(withSchemas && output.source
520
- ? { outputSchemaSource: output.source }
521
- : {}),
522
- ...(renderedOutput?.truncated
523
- ? { outputSchemaTruncated: true }
524
- : {}),
761
+ ...(withSchemas && output.source ? { outputSchemaSource: output.source } : {}),
762
+ ...(renderedOutput?.truncated ? { outputSchemaTruncated: true } : {}),
525
763
  ...(schemaKeys && !renderedInput?.truncated
526
764
  ? {
527
- ...(schemaKeys.inputKeys
528
- ? { inputKeys: schemaKeys.inputKeys }
529
- : {}),
530
- ...(schemaKeys.requiredInputKeys
531
- ? { requiredInputKeys: schemaKeys.requiredInputKeys }
532
- : {}),
765
+ ...(schemaKeys.inputKeys ? { inputKeys: schemaKeys.inputKeys } : {}),
766
+ ...(schemaKeys.requiredInputKeys ? { requiredInputKeys: schemaKeys.requiredInputKeys } : {}),
533
767
  }
534
768
  : {}),
535
- ...(schemaKeys?.outputKeys && !renderedOutput?.truncated
536
- ? { outputKeys: schemaKeys.outputKeys }
537
- : {}),
538
- ...(match.tool.annotations
539
- ? { annotations: match.tool.annotations }
540
- : {}),
541
- // Config lets a program call this write without asking (#566).
542
- // A marker, not a class: the tool stays approval-required.
543
- ...(isApprovalExempt(this.approval, match.connector, match.tool.name, match.tool)
544
- ? { approval: "exempt" }
545
- : {}),
769
+ ...(schemaKeys?.outputKeys && !renderedOutput?.truncated ? { outputKeys: schemaKeys.outputKeys } : {}),
770
+ ...(match.tool.annotations ? { annotations: match.tool.annotations } : {}),
771
+ classification: match.tool.classification,
546
772
  ...(requiredReasons
547
773
  ? {
548
774
  guideRequired: true,
@@ -552,14 +778,9 @@ export class CatalogService {
552
778
  },
553
779
  };
554
780
  });
555
- const nextOffset = offset + entries.length < matches.length
556
- ? offset + entries.length
557
- : undefined;
781
+ const nextOffset = offset + entries.length < matches.length ? offset + entries.length : undefined;
558
782
  const pageTools = new Set(pageMatches.map((match) => match.tool));
559
- const matchingTools = (term) => new Set([
560
- ...(statistics.nameMatches.get(term) ?? []),
561
- ...(statistics.descriptionMatches.get(term) ?? []),
562
- ]);
783
+ const matchingTools = (term) => new Set([...(statistics.nameMatches.get(term) ?? []), ...(statistics.descriptionMatches.get(term) ?? [])]);
563
784
  const representedTerms = [];
564
785
  const otherResultTerms = [];
565
786
  const unmatchedTerms = [];
@@ -576,32 +797,27 @@ export class CatalogService {
576
797
  }
577
798
  }
578
799
  const unavailableCatalogs = catalogs.filter(Result.isFailure).length;
579
- // Named field by field rather than spread: `CallErrorDetails` also carries
580
- // connector, operation, recovery, and nextAction, and a discovery read is
581
- // not a call — widening the classifier must not silently widen what a
582
- // catalog search hands back.
583
- const scopedCatalogError = (() => {
584
- const scopedCatalog = catalogs[0];
585
- if (!scopedConnector ||
586
- !scopedCatalog ||
587
- Result.isSuccess(scopedCatalog)) {
588
- return undefined;
589
- }
590
- const error = classifyCallError(scopedCatalog.failure, "catalog_lookup_failed");
591
- return {
592
- code: error.code,
593
- message: boundedEchoText(error.message),
594
- retryable: error.retryable,
595
- ...(error.retryAfterMs === undefined
596
- ? {}
597
- : { retryAfterMs: error.retryAfterMs }),
598
- };
599
- })();
600
- const safetyLabel = safety === "readOnly"
601
- ? "read-only "
602
- : safety === "approvalRequired"
603
- ? "approval-required "
604
- : "";
800
+ const catalogErrors = (await Promise.all(catalogs.map(async (catalog, index) => {
801
+ if (Result.isSuccess(catalog))
802
+ return [];
803
+ return [await this.catalogFailure(connectors[index], catalog.failure)];
804
+ }))).flat();
805
+ const needsAuth = (error) => error.code === "auth_required" ||
806
+ error.code === "downstream_oauth_required" ||
807
+ error.code === "provider_permission_denied";
808
+ catalogErrors.sort((a, b) => Number(needsAuth(b)) - Number(needsAuth(a)));
809
+ const scopedFailure = args.connector ? catalogErrors[0] : undefined;
810
+ const scopedCatalogError = scopedFailure
811
+ ? scopedFailure.code === "auth_required" || scopedFailure.code === "downstream_oauth_required"
812
+ ? scopedFailure
813
+ : {
814
+ code: scopedFailure.code,
815
+ message: scopedFailure.message,
816
+ retryable: scopedFailure.retryable,
817
+ ...(scopedFailure.retryAfterMs === undefined ? {} : { retryAfterMs: scopedFailure.retryAfterMs }),
818
+ }
819
+ : undefined;
820
+ const safetyLabel = safety === "readOnly" ? "read-only " : safety === "approvalRequired" ? "approval-required " : "";
605
821
  const filterRecovery = safety === "all" ? "" : " Change safety to inspect the other tools.";
606
822
  const scopedGuide = matches.length === 0 && scopedConnector && connectorGuide(scopedConnector)
607
823
  ? {
@@ -610,15 +826,9 @@ export class CatalogService {
610
826
  required: connectorGuideRequired(scopedConnector),
611
827
  }
612
828
  : undefined;
613
- // A connector's own id — the address prefix the caller already has — and
614
- // the title it is displayed under are the most natural first query terms,
615
- // and neither is a document in the lexical index. Indexing them would move
616
- // ranking for every query that already matches tools, so instead a search
617
- // that matched nothing asks the same lexical question of connector
618
- // identity and corrects its own sentence: the deployment plainly has this
619
- // capability, and one scoped browse away are its tools. Unscoped only —
620
- // guidance for an explicit scope already names that connector rather than
621
- // claiming the deployment has nothing.
829
+ // Partial identity words can still explain a miss. Exact identities are
830
+ // already ranking inputs above; this guidance handles a title word such
831
+ // as "warehouse" when the full title is "Warehouse stock".
622
832
  const identityConnectorIds = matches.length === 0 && !isBrowse && !unsearchableQuery && !scopedConnector
623
833
  ? connectors
624
834
  // The full query, not `analyzedTerms`: that cap exists to bound
@@ -626,8 +836,7 @@ export class CatalogService {
626
836
  // term. A ninth term is a real search term, and a search that
627
837
  // ranked against it must not deny the connector it names.
628
838
  .filter((connector) => queryTerms.some((term) => matchesLexicalTerm(connector.id, term) ||
629
- (connector.title !== undefined &&
630
- matchesLexicalTerm(connector.title, term))))
839
+ (connector.title !== undefined && matchesLexicalTerm(connector.title, term))))
631
840
  .map((connector) => connector.id)
632
841
  : [];
633
842
  const namedIdentityConnectors = identityConnectorIds
@@ -637,17 +846,16 @@ export class CatalogService {
637
846
  const unnamedIdentityConnectors = identityConnectorIds.length - MAX_IDENTITY_CONNECTORS;
638
847
  const identityGuidance = identityConnectorIds.length === 0
639
848
  ? undefined
640
- : `No matching ${safetyLabel}capability was found${unavailableCatalogs === 0 ? "" : " in the catalogs that answered"}, but the query names configured connector${identityConnectorIds.length === 1 ? "" : "s"} ${namedIdentityConnectors}${unnamedIdentityConnectors > 0
641
- ? ` and ${unnamedIdentityConnectors} more`
642
- : ""}. Scope by connector and browse with an empty query to list the tools there.${filterRecovery}`;
849
+ : `No matching ${safetyLabel}capability was found${unavailableCatalogs === 0 ? "" : " in the catalogs that answered"}, but the query names configured connector${identityConnectorIds.length === 1 ? "" : "s"} ${namedIdentityConnectors}${unnamedIdentityConnectors > 0 ? ` and ${unnamedIdentityConnectors} more` : ""}. Scope by connector and browse with an empty query to list the tools there.${filterRecovery}`;
643
850
  // A scope that resolved to nothing is the same silence one step earlier in
644
851
  // the lookup: no connector resolved, so no catalog was even attempted, so
645
- // no catalog failed and the unavailable path below never fires. Echo only
646
- // the ID the caller already supplied — naming what else is configured
647
- // would answer a question they did not ask, past a filter they may not
648
- // pass.
852
+ // no catalog failed and the unavailable path below never fires. Name
853
+ // only configured connectors in the current identity/pool registry view.
649
854
  const unknownConnectorGuidance = args.connector && !scopedConnector
650
- ? `Connector "${args.connector}" is not configured in this deployment. Omit connector to search all configured tools.`
855
+ ? `Connector "${args.connector}" is not configured in this deployment. Omit connector to search all configured tools. Configured connectors for this endpoint: ${this.registry
856
+ .listConnectors()
857
+ .map((connector) => connector.id)
858
+ .join(", ") || "none"}.`
651
859
  : undefined;
652
860
  // Searchable queries report analysis when the scorer had to degrade. A
653
861
  // non-empty query with no searchable terms reports the bounded raw input
@@ -655,13 +863,16 @@ export class CatalogService {
655
863
  // no terms to analyse and normally reports none at all. Scope failures are
656
864
  // the exception, because an empty result alone looks like a connector that
657
865
  // correctly exposes no tools.
658
- const reportsQueryAnalysis = unsearchableQuery ||
866
+ const reportsQueryAnalysis = absence !== undefined ||
867
+ unsearchableQuery ||
659
868
  (queryTerms.length > 0
660
869
  ? matchMode === "partial"
661
870
  : unknownConnectorGuidance !== undefined || unavailableCatalogs > 0);
662
871
  const searchGuidance = () => {
663
872
  if (unknownConnectorGuidance)
664
873
  return unknownConnectorGuidance;
874
+ if (absence)
875
+ return absence.message;
665
876
  if (unsearchableQuery) {
666
877
  return scopedConnector && unavailableCatalogs > 0
667
878
  ? `Connector "${scopedConnector.id}" could not be searched because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
@@ -672,7 +883,7 @@ export class CatalogService {
672
883
  return undefined;
673
884
  return scopedConnector
674
885
  ? `Connector "${scopedConnector.id}" could not be browsed because its catalog was unavailable. Inspect catalogError for the typed reason and recovery detail.`
675
- : `${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable, so this browse is incomplete. Scope by connector to see the typed reason.`;
886
+ : `${unavailableCatalogs} connector catalog${unavailableCatalogs === 1 ? " was" : "s were"} unavailable, so this browse is incomplete. Inspect catalogErrors for the typed reasons and recovery actions.`;
676
887
  }
677
888
  if (matches.length === 0) {
678
889
  if (scopedConnector) {
@@ -700,44 +911,39 @@ export class CatalogService {
700
911
  };
701
912
  const guidance = searchGuidance();
702
913
  return {
914
+ catalogErrors,
915
+ ...(absence ? { absence } : {}),
703
916
  entries,
704
917
  total: matches.length,
705
918
  offset,
706
919
  limit,
707
920
  hasMore: nextOffset !== undefined,
708
921
  ...(nextOffset !== undefined ? { nextOffset } : {}),
709
- ...(matchMode === "partial" && matches.length > 0
710
- ? { matchMode }
711
- : {}),
922
+ ...(matchMode === "partial" && matches.length > 0 ? { matchMode } : {}),
712
923
  ...(reportsQueryAnalysis
713
924
  ? {
714
925
  queryAnalysis: {
715
926
  representedTerms,
716
927
  otherResultTerms,
717
928
  unmatchedTerms,
718
- ...(queryMetadataTruncated
719
- ? { truncated: true }
720
- : {}),
929
+ ...(queryMetadataTruncated ? { truncated: true } : {}),
721
930
  ...(args.connector ? { connectorScope: args.connector } : {}),
722
931
  ...(args.connector && !scopedConnector
723
- ? { unknownConnector: true }
724
- : {}),
725
- ...(unavailableCatalogs > 0
726
- ? { unavailableConnectorCount: unavailableCatalogs }
932
+ ? {
933
+ unknownConnector: true,
934
+ configuredConnectors: this.registry.listConnectors().map((connector) => connector.id),
935
+ }
727
936
  : {}),
937
+ ...(unavailableCatalogs > 0 ? { unavailableConnectorCount: unavailableCatalogs } : {}),
728
938
  ...(scopedCatalogError ? { catalogError: scopedCatalogError } : {}),
729
939
  ...(scopedGuide
730
940
  ? {
731
941
  guide: scopedGuide.guide,
732
- ...(scopedGuide.guideSummary
733
- ? { guideSummary: scopedGuide.guideSummary }
734
- : {}),
942
+ ...(scopedGuide.guideSummary ? { guideSummary: scopedGuide.guideSummary } : {}),
735
943
  ...(scopedGuide.required
736
944
  ? {
737
945
  guideRequired: true,
738
- guideRequiredReasons: [
739
- "connector_required",
740
- ],
946
+ guideRequiredReasons: ["connector_required"],
741
947
  }
742
948
  : {}),
743
949
  }
@@ -756,12 +962,21 @@ export class CatalogService {
756
962
  return { address, resolved: this.registry.resolveAddress(address) };
757
963
  });
758
964
  const connectorIds = [
759
- ...new Set(resolved
760
- .map((entry) => entry.resolved?.connector.id)
761
- .filter((id) => Boolean(id))),
965
+ ...new Set(resolved.map((entry) => entry.resolved?.connector.id).filter((id) => Boolean(id))),
762
966
  ];
763
967
  const loaded = await runEdge(this.discoveryCatalogs(connectorIds, "connecta.describe"));
764
968
  const catalogs = new Map(connectorIds.map((id, index) => [id, loaded[index]]));
969
+ const authFailures = new Map(await Promise.all(connectorIds.map(async (id) => {
970
+ const catalog = catalogs.get(id);
971
+ if (!catalog || !Result.isFailure(catalog))
972
+ return [id, undefined];
973
+ const classified = classifyCallError(catalog.failure, "catalog_lookup_failed");
974
+ const connector = this.registry.getConnector(id);
975
+ const error = classified.code === "auth_required" || classified.code === "downstream_oauth_required"
976
+ ? await this.catalogFailure(connector, catalog.failure)
977
+ : undefined;
978
+ return [id, error];
979
+ })));
765
980
  return resolved.map(({ address, resolved: addressResolution }) => {
766
981
  if (!addressResolution) {
767
982
  const message = `Unknown address "${boundedEchoText(address)}"`;
@@ -770,12 +985,16 @@ export class CatalogService {
770
985
  error: message,
771
986
  errorDetails: {
772
987
  ...framingError("unknown_address", message),
988
+ configuredConnectors: this.registry.listConnectors().map((connector) => connector.id),
773
989
  nextAction: this.searchRecovery({ query: recoveryQuery(address) }, "Find the configured canonical address before retrying."),
774
990
  },
775
991
  };
776
992
  }
777
993
  const catalog = catalogs.get(addressResolution.connector.id);
778
994
  if (catalog && Result.isFailure(catalog)) {
995
+ const authFailure = authFailures.get(addressResolution.connector.id);
996
+ if (authFailure)
997
+ return { address: boundedEchoText(address), error: authFailure.message, errorDetails: authFailure };
779
998
  const classified = classifyCallError(catalog.failure, "catalog_lookup_failed");
780
999
  const message = boundedEchoText(classified.message);
781
1000
  return {
@@ -785,9 +1004,7 @@ export class CatalogService {
785
1004
  code: classified.code,
786
1005
  message,
787
1006
  retryable: classified.retryable,
788
- ...(classified.retryAfterMs === undefined
789
- ? {}
790
- : { retryAfterMs: classified.retryAfterMs }),
1007
+ ...(classified.retryAfterMs === undefined ? {} : { retryAfterMs: classified.retryAfterMs }),
791
1008
  },
792
1009
  };
793
1010
  }
@@ -818,16 +1035,15 @@ export class CatalogService {
818
1035
  description: true,
819
1036
  })
820
1037
  : undefined;
821
- const compactInput = format === "compact"
822
- ? compactDescriptionSchema(input) : undefined;
823
- const compactOutput = format === "compact" && output.schema
824
- ? compactDescriptionSchema(output.schema) : undefined;
1038
+ const compactInput = format === "compact" ? compactDescriptionSchema(input) : undefined;
1039
+ const compactOutput = format === "compact" && output.schema ? compactDescriptionSchema(output.schema) : undefined;
825
1040
  const inputTruncated = compactInput?.truncated === true || signature?.inputTruncated === true;
826
1041
  const outputTruncated = compactOutput?.truncated === true || signature?.outputTruncated === true;
827
1042
  const requiredReasons = guideRequiredReasons(addressResolution.connector, tool, inputTruncated || outputTruncated);
828
1043
  const guideSummary = connectorGuideSummary(addressResolution.connector);
829
1044
  return {
830
1045
  address,
1046
+ schemaFormat: format === "json" ? "json" : "text",
831
1047
  name: tool.name,
832
1048
  ...(description !== undefined ? { description } : {}),
833
1049
  ...(connectorGuide(addressResolution.connector)
@@ -842,9 +1058,7 @@ export class CatalogService {
842
1058
  guideRequiredReasons: requiredReasons,
843
1059
  }
844
1060
  : {}),
845
- ...(signature
846
- ? { signature: signature.text }
847
- : { inputSchema: compactInput?.text ?? input }),
1061
+ ...(signature ? { signature: signature.text } : { inputSchema: compactInput?.text ?? input }),
848
1062
  ...(inputTruncated ? { inputSchemaTruncated: true } : {}),
849
1063
  ...(outputTruncated ? { outputSchemaTruncated: true } : {}),
850
1064
  ...(output.schema && !signature
@@ -854,9 +1068,7 @@ export class CatalogService {
854
1068
  : {}),
855
1069
  ...(output.source ? { outputSchemaSource: output.source } : {}),
856
1070
  ...(tool.annotations ? { annotations: tool.annotations } : {}),
857
- ...(isApprovalExempt(this.approval, addressResolution.connector, tool.name, tool)
858
- ? { approval: "exempt" }
859
- : {}),
1071
+ classification: tool.classification,
860
1072
  };
861
1073
  });
862
1074
  }
@@ -872,45 +1084,17 @@ function pageTail(page) {
872
1084
  ...(page.queryAnalysis ? { queryAnalysis: page.queryAnalysis } : {}),
873
1085
  };
874
1086
  }
875
- export function groupedSearchResult(page) {
876
- const groups = [];
877
- const byConnector = new Map();
878
- for (const entry of page.entries) {
879
- const existing = byConnector.get(entry.connector.id);
880
- if (existing) {
881
- existing.tools.push(entry.tool);
882
- }
883
- else {
884
- const group = {
885
- id: entry.connector.id,
886
- ...(entry.connector.title ? { title: entry.connector.title } : {}),
887
- ...(entry.guide ? { guide: entry.guide } : {}),
888
- ...(entry.guideSummary
889
- ? { guideSummary: entry.guideSummary }
890
- : {}),
891
- tools: [],
892
- };
893
- byConnector.set(entry.connector.id, group);
894
- groups.push(group);
895
- group.tools.push(entry.tool);
896
- }
897
- }
898
- return {
899
- connectors: groups,
900
- ...pageTail(page),
901
- };
902
- }
903
1087
  export function flatSearchResult(page) {
904
1088
  return {
1089
+ catalogErrors: page.catalogErrors,
1090
+ ...(page.absence ? { absence: page.absence } : {}),
905
1091
  tools: page.entries.map((entry) => ({
906
1092
  ...entry.tool,
907
1093
  ...(entry.connector.title
908
1094
  ? { connectorTitle: boundedEchoText(entry.connector.title.replace(/\s+/g, " ").trim(), 117) }
909
1095
  : {}),
910
1096
  ...(entry.guide ? { guide: entry.guide } : {}),
911
- ...(entry.guideSummary
912
- ? { guideSummary: entry.guideSummary }
913
- : {}),
1097
+ ...(entry.guideSummary ? { guideSummary: entry.guideSummary } : {}),
914
1098
  })),
915
1099
  ...pageTail(page),
916
1100
  };