@zackbart/connecta 0.15.1 → 0.16.1

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 (335) hide show
  1. package/AGENTS.md +40 -20
  2. package/CHANGELOG.md +653 -0
  3. package/README.md +25 -5
  4. package/bin/connecta.mjs +44 -3
  5. package/dist/access-tokens.d.ts +0 -1
  6. package/dist/access-tokens.js +0 -1
  7. package/dist/activity.d.ts +50 -2
  8. package/dist/activity.js +34 -1
  9. package/dist/apps-shell.d.ts +0 -1
  10. package/dist/apps-shell.js +0 -1
  11. package/dist/auth/bearer.d.ts +0 -1
  12. package/dist/auth/bearer.js +0 -1
  13. package/dist/auth/clerk.d.ts +0 -1
  14. package/dist/auth/clerk.js +45 -9
  15. package/dist/auth/downstream-oauth.d.ts +0 -1
  16. package/dist/auth/downstream-oauth.js +0 -1
  17. package/dist/call-admission.d.ts +0 -1
  18. package/dist/call-admission.js +0 -1
  19. package/dist/catalog-drift.d.ts +99 -0
  20. package/dist/catalog-drift.js +251 -0
  21. package/dist/catalog-fingerprint.d.ts +0 -1
  22. package/dist/catalog-fingerprint.js +0 -1
  23. package/dist/catalog-limits.d.ts +0 -1
  24. package/dist/catalog-limits.js +0 -1
  25. package/dist/catalog-service.d.ts +0 -1
  26. package/dist/catalog-service.js +41 -5
  27. package/dist/catalog.d.ts +11 -1
  28. package/dist/catalog.js +20 -1
  29. package/dist/concurrency.d.ts +0 -1
  30. package/dist/concurrency.js +0 -1
  31. package/dist/connector-scope.d.ts +0 -1
  32. package/dist/connector-scope.js +0 -1
  33. package/dist/connectors/api.d.ts +29 -21
  34. package/dist/connectors/api.js +45 -19
  35. package/dist/connectors/guarded-fetch.d.ts +106 -0
  36. package/dist/connectors/guarded-fetch.js +289 -0
  37. package/dist/connectors/remote-mcp.d.ts +0 -1
  38. package/dist/connectors/remote-mcp.js +0 -1
  39. package/dist/credentials.d.ts +0 -1
  40. package/dist/credentials.js +0 -1
  41. package/dist/errors.d.ts +30 -3
  42. package/dist/errors.js +1 -1
  43. package/dist/execute.d.ts +0 -1
  44. package/dist/execute.js +0 -1
  45. package/dist/executor-admission.d.ts +11 -1
  46. package/dist/executor-admission.js +28 -1
  47. package/dist/executor-result.d.ts +0 -1
  48. package/dist/executor-result.js +0 -1
  49. package/dist/executors/quickjs-child.d.ts +0 -1
  50. package/dist/executors/quickjs-child.js +0 -1
  51. package/dist/executors/quickjs-protocol.d.ts +0 -1
  52. package/dist/executors/quickjs-protocol.js +0 -1
  53. package/dist/executors/quickjs-runtime.d.ts +0 -1
  54. package/dist/executors/quickjs-runtime.js +0 -1
  55. package/dist/executors/quickjs.d.ts +0 -1
  56. package/dist/executors/quickjs.js +2 -1
  57. package/dist/favicon.d.ts +0 -1
  58. package/dist/favicon.js +0 -1
  59. package/dist/index.d.ts +2 -3
  60. package/dist/index.js +24 -7
  61. package/dist/invocation.d.ts +0 -1
  62. package/dist/invocation.js +0 -1
  63. package/dist/json-schema.d.ts +0 -1
  64. package/dist/json-schema.js +0 -1
  65. package/dist/mcp-result.d.ts +0 -1
  66. package/dist/mcp-result.js +0 -1
  67. package/dist/meta-tools.d.ts +0 -1
  68. package/dist/meta-tools.js +0 -1
  69. package/dist/node.d.ts +0 -1
  70. package/dist/node.js +0 -1
  71. package/dist/operator-ui/generated.d.ts +2 -3
  72. package/dist/operator-ui/generated.js +3 -4
  73. package/dist/operator-ui/model.d.ts +10 -1
  74. package/dist/operator-ui/model.js +0 -1
  75. package/dist/operator-ui/view.d.ts +167 -0
  76. package/dist/operator-ui/view.js +270 -0
  77. package/dist/providers/cloudflare.d.ts +0 -1
  78. package/dist/providers/cloudflare.js +261 -299
  79. package/dist/providers/linear.d.ts +26 -5
  80. package/dist/providers/linear.js +32 -61
  81. package/dist/providers/mixpanel.d.ts +35 -4
  82. package/dist/providers/mixpanel.js +53 -79
  83. package/dist/providers/notion.d.ts +0 -1
  84. package/dist/providers/notion.js +87 -42
  85. package/dist/providers/stripe.d.ts +13 -1
  86. package/dist/providers/stripe.js +31 -60
  87. package/dist/registry.d.ts +34 -2
  88. package/dist/registry.js +85 -5
  89. package/dist/retired-toolkits.d.ts +0 -1
  90. package/dist/retired-toolkits.js +0 -1
  91. package/dist/routes/access-tokens.d.ts +0 -1
  92. package/dist/routes/access-tokens.js +0 -1
  93. package/dist/routes/activity.d.ts +0 -1
  94. package/dist/routes/activity.js +0 -1
  95. package/dist/routes/credentials.d.ts +0 -1
  96. package/dist/routes/credentials.js +0 -1
  97. package/dist/routes/mcp.d.ts +0 -1
  98. package/dist/routes/mcp.js +0 -1
  99. package/dist/routes/oauth.d.ts +0 -1
  100. package/dist/routes/oauth.js +0 -1
  101. package/dist/routes/shared.d.ts +2 -1
  102. package/dist/routes/shared.js +0 -1
  103. package/dist/routes/ui.d.ts +0 -1
  104. package/dist/routes/ui.js +0 -1
  105. package/dist/server.d.ts +0 -1
  106. package/dist/server.js +11 -1
  107. package/dist/skills.d.ts +0 -1
  108. package/dist/skills.js +0 -1
  109. package/dist/storage/file.d.ts +0 -1
  110. package/dist/storage/file.js +0 -1
  111. package/dist/storage/memory.d.ts +0 -1
  112. package/dist/storage/memory.js +0 -1
  113. package/dist/timeout.d.ts +0 -1
  114. package/dist/timeout.js +0 -1
  115. package/dist/tool-safety.d.ts +0 -1
  116. package/dist/tool-safety.js +0 -1
  117. package/dist/types.d.ts +45 -1
  118. package/dist/types.js +0 -1
  119. package/dist/ui.d.ts +0 -1
  120. package/dist/ui.js +13 -137
  121. package/dist/validate.d.ts +13 -15
  122. package/dist/validate.js +24 -11
  123. package/dist/version.d.ts +1 -2
  124. package/dist/version.js +1 -2
  125. package/documentation/architecture.md +186 -5
  126. package/documentation/auth.md +11 -0
  127. package/documentation/call-admission.md +142 -6
  128. package/documentation/cloudflare.md +92 -10
  129. package/documentation/code-first-exploration.md +2 -2
  130. package/documentation/code-mode.md +14 -14
  131. package/documentation/connector-guides.md +172 -5
  132. package/documentation/connectors.md +89 -0
  133. package/documentation/linear.md +20 -2
  134. package/documentation/mcp-ui-design.md +2 -2
  135. package/documentation/meta-tools.md +7 -1
  136. package/documentation/mixpanel.md +42 -9
  137. package/documentation/notion.md +23 -2
  138. package/documentation/operations.md +325 -5
  139. package/documentation/operator-ui.md +120 -5
  140. package/documentation/provider-audit.md +160 -0
  141. package/documentation/provider-conventions.md +667 -0
  142. package/documentation/request-admission.md +167 -5
  143. package/documentation/rich-output-design.md +1 -2
  144. package/documentation/stripe.md +15 -0
  145. package/documentation/upgrading.md +454 -0
  146. package/ethos.md +14 -9
  147. package/examples/worker/README.md +102 -12
  148. package/examples/worker/src/index.ts +46 -1
  149. package/examples/worker/wrangler.jsonc +14 -1
  150. package/package.json +10 -6
  151. package/templates/node/.dockerignore +10 -0
  152. package/templates/node/.env.example +40 -3
  153. package/templates/node/AGENTS.md +16 -1
  154. package/templates/node/Dockerfile +49 -0
  155. package/templates/node/README.md +117 -2
  156. package/templates/node/docker-compose.yml +39 -0
  157. package/templates/node/package.json +1 -1
  158. package/templates/node/src/file-activity.ts +182 -0
  159. package/templates/node/src/index.ts +80 -4
  160. package/assets/connecta-clay-hero.png +0 -0
  161. package/dist/access-tokens.d.ts.map +0 -1
  162. package/dist/access-tokens.js.map +0 -1
  163. package/dist/activity.d.ts.map +0 -1
  164. package/dist/activity.js.map +0 -1
  165. package/dist/apps-shell.d.ts.map +0 -1
  166. package/dist/apps-shell.js.map +0 -1
  167. package/dist/auth/bearer.d.ts.map +0 -1
  168. package/dist/auth/bearer.js.map +0 -1
  169. package/dist/auth/clerk.d.ts.map +0 -1
  170. package/dist/auth/clerk.js.map +0 -1
  171. package/dist/auth/downstream-oauth.d.ts.map +0 -1
  172. package/dist/auth/downstream-oauth.js.map +0 -1
  173. package/dist/call-admission.d.ts.map +0 -1
  174. package/dist/call-admission.js.map +0 -1
  175. package/dist/catalog-fingerprint.d.ts.map +0 -1
  176. package/dist/catalog-fingerprint.js.map +0 -1
  177. package/dist/catalog-limits.d.ts.map +0 -1
  178. package/dist/catalog-limits.js.map +0 -1
  179. package/dist/catalog-service.d.ts.map +0 -1
  180. package/dist/catalog-service.js.map +0 -1
  181. package/dist/catalog.d.ts.map +0 -1
  182. package/dist/catalog.js.map +0 -1
  183. package/dist/concurrency.d.ts.map +0 -1
  184. package/dist/concurrency.js.map +0 -1
  185. package/dist/connector-scope.d.ts.map +0 -1
  186. package/dist/connector-scope.js.map +0 -1
  187. package/dist/connectors/api.d.ts.map +0 -1
  188. package/dist/connectors/api.js.map +0 -1
  189. package/dist/connectors/remote-mcp.d.ts.map +0 -1
  190. package/dist/connectors/remote-mcp.js.map +0 -1
  191. package/dist/credentials.d.ts.map +0 -1
  192. package/dist/credentials.js.map +0 -1
  193. package/dist/errors.d.ts.map +0 -1
  194. package/dist/errors.js.map +0 -1
  195. package/dist/execute.d.ts.map +0 -1
  196. package/dist/execute.js.map +0 -1
  197. package/dist/executor-admission.d.ts.map +0 -1
  198. package/dist/executor-admission.js.map +0 -1
  199. package/dist/executor-result.d.ts.map +0 -1
  200. package/dist/executor-result.js.map +0 -1
  201. package/dist/executors/quickjs-child.d.ts.map +0 -1
  202. package/dist/executors/quickjs-child.js.map +0 -1
  203. package/dist/executors/quickjs-protocol.d.ts.map +0 -1
  204. package/dist/executors/quickjs-protocol.js.map +0 -1
  205. package/dist/executors/quickjs-runtime.d.ts.map +0 -1
  206. package/dist/executors/quickjs-runtime.js.map +0 -1
  207. package/dist/executors/quickjs.d.ts.map +0 -1
  208. package/dist/executors/quickjs.js.map +0 -1
  209. package/dist/favicon.d.ts.map +0 -1
  210. package/dist/favicon.js.map +0 -1
  211. package/dist/index.d.ts.map +0 -1
  212. package/dist/index.js.map +0 -1
  213. package/dist/invocation.d.ts.map +0 -1
  214. package/dist/invocation.js.map +0 -1
  215. package/dist/json-schema.d.ts.map +0 -1
  216. package/dist/json-schema.js.map +0 -1
  217. package/dist/mcp-result.d.ts.map +0 -1
  218. package/dist/mcp-result.js.map +0 -1
  219. package/dist/meta-tools.d.ts.map +0 -1
  220. package/dist/meta-tools.js.map +0 -1
  221. package/dist/node.d.ts.map +0 -1
  222. package/dist/node.js.map +0 -1
  223. package/dist/operator-ui/generated.d.ts.map +0 -1
  224. package/dist/operator-ui/generated.js.map +0 -1
  225. package/dist/operator-ui/model.d.ts.map +0 -1
  226. package/dist/operator-ui/model.js.map +0 -1
  227. package/dist/providers/cloudflare.d.ts.map +0 -1
  228. package/dist/providers/cloudflare.js.map +0 -1
  229. package/dist/providers/linear.d.ts.map +0 -1
  230. package/dist/providers/linear.js.map +0 -1
  231. package/dist/providers/mixpanel.d.ts.map +0 -1
  232. package/dist/providers/mixpanel.js.map +0 -1
  233. package/dist/providers/notion.d.ts.map +0 -1
  234. package/dist/providers/notion.js.map +0 -1
  235. package/dist/providers/stripe.d.ts.map +0 -1
  236. package/dist/providers/stripe.js.map +0 -1
  237. package/dist/registry.d.ts.map +0 -1
  238. package/dist/registry.js.map +0 -1
  239. package/dist/retired-toolkits.d.ts.map +0 -1
  240. package/dist/retired-toolkits.js.map +0 -1
  241. package/dist/routes/access-tokens.d.ts.map +0 -1
  242. package/dist/routes/access-tokens.js.map +0 -1
  243. package/dist/routes/activity.d.ts.map +0 -1
  244. package/dist/routes/activity.js.map +0 -1
  245. package/dist/routes/credentials.d.ts.map +0 -1
  246. package/dist/routes/credentials.js.map +0 -1
  247. package/dist/routes/mcp.d.ts.map +0 -1
  248. package/dist/routes/mcp.js.map +0 -1
  249. package/dist/routes/oauth.d.ts.map +0 -1
  250. package/dist/routes/oauth.js.map +0 -1
  251. package/dist/routes/shared.d.ts.map +0 -1
  252. package/dist/routes/shared.js.map +0 -1
  253. package/dist/routes/ui.d.ts.map +0 -1
  254. package/dist/routes/ui.js.map +0 -1
  255. package/dist/server.d.ts.map +0 -1
  256. package/dist/server.js.map +0 -1
  257. package/dist/skills.d.ts.map +0 -1
  258. package/dist/skills.js.map +0 -1
  259. package/dist/storage/file.d.ts.map +0 -1
  260. package/dist/storage/file.js.map +0 -1
  261. package/dist/storage/memory.d.ts.map +0 -1
  262. package/dist/storage/memory.js.map +0 -1
  263. package/dist/timeout.d.ts.map +0 -1
  264. package/dist/timeout.js.map +0 -1
  265. package/dist/tool-safety.d.ts.map +0 -1
  266. package/dist/tool-safety.js.map +0 -1
  267. package/dist/types.d.ts.map +0 -1
  268. package/dist/types.js.map +0 -1
  269. package/dist/ui.d.ts.map +0 -1
  270. package/dist/ui.js.map +0 -1
  271. package/dist/validate.d.ts.map +0 -1
  272. package/dist/validate.js.map +0 -1
  273. package/dist/version.d.ts.map +0 -1
  274. package/dist/version.js.map +0 -1
  275. package/examples/node/README.md +0 -52
  276. package/examples/node/src/index.ts +0 -71
  277. package/src/access-tokens.ts +0 -289
  278. package/src/activity.ts +0 -234
  279. package/src/apps-shell.ts +0 -370
  280. package/src/auth/bearer.ts +0 -61
  281. package/src/auth/clerk.ts +0 -553
  282. package/src/auth/downstream-oauth.ts +0 -575
  283. package/src/call-admission.ts +0 -521
  284. package/src/catalog-fingerprint.ts +0 -52
  285. package/src/catalog-limits.ts +0 -11
  286. package/src/catalog-service.ts +0 -1153
  287. package/src/catalog.ts +0 -790
  288. package/src/concurrency.ts +0 -39
  289. package/src/connector-scope.ts +0 -66
  290. package/src/connectors/api.ts +0 -159
  291. package/src/connectors/remote-mcp.ts +0 -1090
  292. package/src/credentials.ts +0 -428
  293. package/src/errors.ts +0 -364
  294. package/src/execute.ts +0 -1406
  295. package/src/executor-admission.ts +0 -328
  296. package/src/executor-result.ts +0 -107
  297. package/src/executors/quickjs-child.ts +0 -172
  298. package/src/executors/quickjs-protocol.ts +0 -99
  299. package/src/executors/quickjs-runtime.ts +0 -470
  300. package/src/executors/quickjs.ts +0 -776
  301. package/src/favicon.ts +0 -53
  302. package/src/index.ts +0 -666
  303. package/src/invocation.ts +0 -655
  304. package/src/json-schema.ts +0 -11
  305. package/src/mcp-result.ts +0 -43
  306. package/src/meta-tools.ts +0 -1607
  307. package/src/node.ts +0 -206
  308. package/src/operator-ui/browser.css +0 -423
  309. package/src/operator-ui/browser.ts +0 -1281
  310. package/src/operator-ui/generated.ts +0 -4
  311. package/src/operator-ui/model.ts +0 -111
  312. package/src/providers/cloudflare.ts +0 -3999
  313. package/src/providers/linear.ts +0 -302
  314. package/src/providers/mixpanel.ts +0 -229
  315. package/src/providers/notion.ts +0 -1879
  316. package/src/providers/stripe.ts +0 -307
  317. package/src/registry.ts +0 -1085
  318. package/src/retired-toolkits.ts +0 -14
  319. package/src/routes/access-tokens.ts +0 -115
  320. package/src/routes/activity.ts +0 -215
  321. package/src/routes/credentials.ts +0 -254
  322. package/src/routes/mcp.ts +0 -429
  323. package/src/routes/oauth.ts +0 -322
  324. package/src/routes/shared.ts +0 -247
  325. package/src/routes/ui.ts +0 -152
  326. package/src/server.ts +0 -195
  327. package/src/skills.ts +0 -271
  328. package/src/storage/file.ts +0 -123
  329. package/src/storage/memory.ts +0 -44
  330. package/src/timeout.ts +0 -84
  331. package/src/tool-safety.ts +0 -15
  332. package/src/types.ts +0 -523
  333. package/src/ui.ts +0 -744
  334. package/src/validate.ts +0 -387
  335. package/src/version.ts +0 -7
package/src/meta-tools.ts DELETED
@@ -1,1607 +0,0 @@
1
- import type { McpServer } from "@modelcontextprotocol/server";
2
- import { z } from "zod";
3
- import type {
4
- ActivityCallSource,
5
- ActivityRequestContext,
6
- } from "./activity.js";
7
- import {
8
- boundedDiscoveryText,
9
- CatalogService,
10
- DEFAULT_SEARCH_LIMIT,
11
- DiscoveryPolicyError,
12
- groupedSearchResult,
13
- MAX_DESCRIBE_ADDRESSES,
14
- MAX_DISCOVERY_RESULT_BYTES,
15
- MAX_SEARCH_LIMIT,
16
- } from "./catalog-service.js";
17
- import { resolveDiscoveryConcurrency } from "./concurrency.js";
18
- import type { CallErrorDetails } from "./errors.js";
19
- import {
20
- InvocationService,
21
- MAX_RETRY_BACKOFF_MS,
22
- retryBackoffMs,
23
- type InvocationTiming,
24
- } from "./invocation.js";
25
- import {
26
- isValidMaxResultBytes,
27
- MIN_MAX_RESULT_BYTES,
28
- resolveMaxResultBytes,
29
- type RegistryView,
30
- } from "./registry.js";
31
- import {
32
- hasConnectorGuides,
33
- listSkills,
34
- resolveSkill,
35
- } from "./skills.js";
36
- import {
37
- DEFAULT_PROBE_TIMEOUT_MS,
38
- normalizeTimeoutMs,
39
- } from "./timeout.js";
40
- import type { KVStorage } from "./types.js";
41
-
42
- export {
43
- MAX_DESCRIBE_ADDRESSES,
44
- MAX_DISCOVERY_RESULT_BYTES,
45
- MAX_RETRY_BACKOFF_MS,
46
- MAX_SEARCH_LIMIT,
47
- retryBackoffMs,
48
- };
49
-
50
- interface TextContent {
51
- type: "text";
52
- text: string;
53
- }
54
- export interface ToolResult {
55
- content: TextContent[];
56
- isError?: boolean;
57
- structuredContent?: Record<string, unknown>;
58
- [x: string]: unknown;
59
- }
60
-
61
- const RESULT_TTL_SECONDS = 900;
62
- const enc = new TextEncoder();
63
- const dec = new TextDecoder();
64
-
65
- export function jsonResult(obj: unknown): ToolResult {
66
- return {
67
- content: [{ type: "text", text: JSON.stringify(obj) }],
68
- ...(obj !== null && typeof obj === "object" && !Array.isArray(obj)
69
- ? { structuredContent: obj as Record<string, unknown> }
70
- : {}),
71
- };
72
- }
73
-
74
- export function errorResult(message: string): ToolResult {
75
- return { content: [{ type: "text", text: message }], isError: true };
76
- }
77
-
78
- function msg(err: unknown): string {
79
- return err instanceof Error ? err.message : String(err);
80
- }
81
-
82
- function discoveryErrorResult(error: DiscoveryPolicyError): ToolResult {
83
- const result = jsonResult({
84
- error: {
85
- code: error.code,
86
- message: error.message,
87
- retryable: false,
88
- },
89
- });
90
- result.isError = true;
91
- return result;
92
- }
93
-
94
- async function discoveryResult(
95
- operation: () => unknown | Promise<unknown>,
96
- hint: string,
97
- ): Promise<ToolResult> {
98
- try {
99
- const value = await operation();
100
- const text = boundedDiscoveryText(value, hint);
101
- return {
102
- content: [{ type: "text", text }],
103
- ...(value !== null && typeof value === "object" && !Array.isArray(value)
104
- ? { structuredContent: value as Record<string, unknown> }
105
- : {}),
106
- };
107
- } catch (err) {
108
- if (err instanceof DiscoveryPolicyError) {
109
- return discoveryErrorResult(err);
110
- }
111
- throw err;
112
- }
113
- }
114
-
115
- /** True if `b` is a UTF-8 continuation byte (0b10xxxxxx). */
116
- function isContinuationByte(b: number | undefined): boolean {
117
- return b !== undefined && (b & 0xc0) === 0x80;
118
- }
119
-
120
- /** Smallest accepted `get_result` byte offset. */
121
- const MIN_RESULT_OFFSET = 0;
122
-
123
- /**
124
- * The one definition of a usable `get_result` offset: a whole number of bytes
125
- * at or past {@link MIN_RESULT_OFFSET}. Shared by the registered zod schema and
126
- * the handler's own check, the way `isValidMaxResultBytes` is shared across the
127
- * cap's intake points (issue #32) — so a value valid at the wire is valid in
128
- * process, and the two cannot drift.
129
- *
130
- * Everything else is rejected rather than coerced, because coercion is how an
131
- * out-of-domain offset used to void a result silently: `Math.max(0, NaN)` is
132
- * `NaN`, which slices to nothing, serializes as `"offset": null`, and reports
133
- * no `nextOffset` — a caller sees a successful, empty result instead of an
134
- * error. An offset past the end of the payload stays legal: it is a whole
135
- * number of bytes, and it answers with an empty final page.
136
- */
137
- function isValidResultOffset(value: number): boolean {
138
- return Number.isInteger(value) && value >= MIN_RESULT_OFFSET;
139
- }
140
-
141
- /**
142
- * Move a byte `offset` back to the nearest UTF-8 codepoint boundary in
143
- * `[0, offset]`, so decoding from it never starts mid-character (which emits
144
- * U+FFFD for the severed tail).
145
- *
146
- * Backwards, never forwards: re-serving a few bytes the caller already has is
147
- * recoverable, silently skipping the rest of a character is not. Offsets the
148
- * server itself produced (`nextOffset`) are already boundaries and come back
149
- * unchanged, so this only moves an offset a client computed on its own
150
- * (issue #38). An offset at or past `bytes.length` is left alone — there is no
151
- * character there to split.
152
- */
153
- export function alignStartToCharBoundary(
154
- bytes: Uint8Array,
155
- offset: number,
156
- ): number {
157
- let o = offset;
158
- while (o > 0 && isContinuationByte(bytes[o])) o--;
159
- return o;
160
- }
161
-
162
- /**
163
- * Move a byte `end` back to the nearest UTF-8 codepoint boundary in
164
- * `(offset, total]`, so decoding `bytes[offset, end)` never splits a codepoint
165
- * (which would emit U+FFFD and break byte-exact reassembly). If backing up
166
- * would make no progress — a single codepoint wider than the window — extend
167
- * forward to the end of that codepoint instead so paging always advances.
168
- * Assumes `offset` is itself a codepoint boundary (offsets are the prior
169
- * `nextOffset`, which this function guarantees, and 0 is always a boundary).
170
- *
171
- * The return is always `> offset` while `offset < total`, whatever `end` is
172
- * asked for. That is the belt-and-braces half of issue #32: cap validation
173
- * keeps an empty window from arising in the first place, and this keeps an
174
- * empty window from turning into a `nextOffset === offset` paging loop if one
175
- * ever does. Exported for direct testing of that invariant.
176
- */
177
- export function alignEndToCharBoundary(
178
- bytes: Uint8Array,
179
- offset: number,
180
- end: number,
181
- total: number,
182
- ): number {
183
- if (end >= total) return total;
184
- // A window that reaches no further than `offset` yields no bytes and no
185
- // progress; widen it to one byte and let the codepoint walk below finish it.
186
- const wanted = Math.max(end, offset + 1);
187
- let e = wanted;
188
- while (e > offset && isContinuationByte(bytes[e])) e--;
189
- if (e === offset) {
190
- // Window is narrower than the codepoint at `offset`; take the whole thing.
191
- e = wanted;
192
- while (e < total && isContinuationByte(bytes[e])) e++;
193
- }
194
- return e;
195
- }
196
-
197
- // --- fields selection (feature 2) -----------------------------------------
198
-
199
- /** Resolve a dot-path (segments) against a value; `key[]` maps the tail over an array. */
200
- function resolvePath(value: unknown, segments: string[]): unknown {
201
- const seg = segments[0];
202
- if (seg === undefined) return value;
203
- const rest = segments.slice(1);
204
- const isArr = seg.endsWith("[]");
205
- const key = isArr ? seg.slice(0, -2) : seg;
206
- let next: unknown = value;
207
- if (key !== "") {
208
- if (value === null || typeof value !== "object") return undefined;
209
- next = (value as Record<string, unknown>)[key];
210
- }
211
- if (isArr) {
212
- if (!Array.isArray(next)) return undefined;
213
- return next.map((el) => resolvePath(el, rest));
214
- }
215
- return resolvePath(next, rest);
216
- }
217
-
218
- const MAX_PROJECTION_AVAILABLE_FIELDS = 20;
219
- const MAX_PROJECTION_SCHEMA_DEPTH = 12;
220
- const MAX_PROJECTION_SCHEMA_NODES = 200;
221
- const MAX_PROJECTION_SCHEMA_PATHS = 100;
222
- const MAX_PROJECTION_PATH_CHARS = 256;
223
- const MAX_PROJECTION_PATH_BYTES = 512;
224
- const MAX_PROJECTION_TOTAL_PATH_CHARS = 512;
225
- const MAX_PROJECTION_TOTAL_PATH_BYTES = 768;
226
- const JSON_SCHEMA_TYPES = new Set([
227
- "array",
228
- "boolean",
229
- "integer",
230
- "null",
231
- "number",
232
- "object",
233
- "string",
234
- ]);
235
- const NON_SEMANTIC_REF_SIBLINGS = new Set([
236
- "$anchor",
237
- "$comment",
238
- "$defs",
239
- "$id",
240
- "$ref",
241
- "$schema",
242
- "default",
243
- "definitions",
244
- "deprecated",
245
- "description",
246
- "examples",
247
- "readOnly",
248
- "title",
249
- "writeOnly",
250
- ]);
251
-
252
- interface FieldProjection {
253
- data: Record<string, unknown>;
254
- unmatchedFields: string[];
255
- }
256
-
257
- interface ProjectionFeedback {
258
- data: Record<string, unknown>;
259
- $connecta: {
260
- type: "field_projection";
261
- unmatchedFields: string[];
262
- hint?: string;
263
- schemaDeclared?: true;
264
- schemaCoverage?: "complete" | "partial";
265
- invalidFields?: string[];
266
- availableFields?: string[];
267
- availableFieldsTruncated?: true;
268
- };
269
- }
270
-
271
- interface SchemaFieldAnalysis {
272
- paths: string[];
273
- complete: boolean;
274
- truncated: boolean;
275
- }
276
-
277
- function localSchemaRef(root: unknown, ref: string): unknown | undefined {
278
- if (ref === "#") return root;
279
- if (
280
- !ref.startsWith("#/") ||
281
- ref.length > 2_048 ||
282
- /~(?![01])/.test(ref)
283
- ) {
284
- return undefined;
285
- }
286
- const segments = ref.slice(2).split("/");
287
- if (segments.length > MAX_PROJECTION_SCHEMA_DEPTH) return undefined;
288
- let current = root;
289
- for (const encoded of segments) {
290
- if (current === null || typeof current !== "object") return undefined;
291
- const key = encoded.replaceAll("~1", "/").replaceAll("~0", "~");
292
- if (!Object.prototype.hasOwnProperty.call(current, key)) return undefined;
293
- current = (current as Record<string, unknown>)[key];
294
- }
295
- return current;
296
- }
297
-
298
- function hasSchemaType(
299
- schema: Record<string, unknown>,
300
- wanted: string,
301
- ): boolean {
302
- return (
303
- schema.type === wanted ||
304
- (Array.isArray(schema.type) && schema.type.includes(wanted))
305
- );
306
- }
307
-
308
- function selectableFieldName(name: string): boolean {
309
- return name.length > 0 && !name.includes(".") && !name.endsWith("[]");
310
- }
311
-
312
- /**
313
- * Collect selectable output paths without trusting a schema more than JSON
314
- * Schema permits. Traversal is iterative and budgeted before sorting or
315
- * rendering, so a cyclic, extremely deep, or extremely broad downstream
316
- * schema cannot turn projection feedback into unbounded host work.
317
- */
318
- function analyzeSchemaFields(root: unknown): SchemaFieldAnalysis {
319
- interface PendingSchema {
320
- schema: unknown;
321
- prefix: string;
322
- depth: number;
323
- ancestors: Set<unknown>;
324
- }
325
- const pending: PendingSchema[] = [
326
- { schema: root, prefix: "", depth: 0, ancestors: new Set() },
327
- ];
328
- const paths = new Set<string>();
329
- let nodes = 0;
330
- let totalPathChars = 0;
331
- let totalPathBytes = 0;
332
- let complete = true;
333
- let truncated = false;
334
-
335
- const addPath = (path: string): boolean => {
336
- if (paths.has(path)) return true;
337
- // Check UTF-16 length before encoding, so a hostile multi-megabyte key
338
- // never causes a same-sized temporary allocation merely to reject it.
339
- if (
340
- path.length > MAX_PROJECTION_PATH_CHARS ||
341
- totalPathChars + path.length > MAX_PROJECTION_TOTAL_PATH_CHARS
342
- ) {
343
- complete = false;
344
- truncated = true;
345
- return false;
346
- }
347
- const pathBytes = enc.encode(path).length;
348
- if (
349
- pathBytes > MAX_PROJECTION_PATH_BYTES ||
350
- totalPathBytes + pathBytes > MAX_PROJECTION_TOTAL_PATH_BYTES ||
351
- paths.size >= MAX_PROJECTION_SCHEMA_PATHS
352
- ) {
353
- complete = false;
354
- truncated = true;
355
- return false;
356
- }
357
- paths.add(path);
358
- totalPathChars += path.length;
359
- totalPathBytes += pathBytes;
360
- return true;
361
- };
362
- const enqueue = (item: PendingSchema): boolean => {
363
- if (nodes + pending.length >= MAX_PROJECTION_SCHEMA_NODES) {
364
- complete = false;
365
- truncated = true;
366
- return false;
367
- }
368
- pending.push(item);
369
- return true;
370
- };
371
-
372
- while (pending.length > 0) {
373
- const item = pending.pop()!;
374
- if (item.depth > MAX_PROJECTION_SCHEMA_DEPTH) {
375
- complete = false;
376
- truncated = true;
377
- continue;
378
- }
379
- if (++nodes > MAX_PROJECTION_SCHEMA_NODES) {
380
- complete = false;
381
- truncated = true;
382
- break;
383
- }
384
- if (item.schema === false) continue;
385
- if (
386
- item.schema === true ||
387
- item.schema === null ||
388
- typeof item.schema !== "object" ||
389
- item.ancestors.has(item.schema)
390
- ) {
391
- complete = false;
392
- continue;
393
- }
394
-
395
- const schema = item.schema as Record<string, unknown>;
396
- const ancestors = new Set(item.ancestors).add(item.schema);
397
- let recognized = false;
398
- if (
399
- schema.type !== undefined &&
400
- !(
401
- (typeof schema.type === "string" &&
402
- JSON_SCHEMA_TYPES.has(schema.type)) ||
403
- (Array.isArray(schema.type) &&
404
- schema.type.length > 0 &&
405
- schema.type.every(
406
- (type) =>
407
- typeof type === "string" && JSON_SCHEMA_TYPES.has(type),
408
- ))
409
- )
410
- ) {
411
- complete = false;
412
- }
413
-
414
- if (schema.$ref !== undefined) {
415
- recognized = true;
416
- let hasSemanticSiblings = false;
417
- for (const key in schema) {
418
- if (
419
- Object.prototype.hasOwnProperty.call(schema, key) &&
420
- !NON_SEMANTIC_REF_SIBLINGS.has(key)
421
- ) {
422
- hasSemanticSiblings = true;
423
- break;
424
- }
425
- }
426
- if (hasSemanticSiblings) {
427
- // Modern JSON Schema applies $ref siblings as an intersection. A
428
- // compact field walker cannot prove that intersection's selectable
429
- // paths, so do not publish paths from either half as available.
430
- complete = false;
431
- continue;
432
- }
433
- const target =
434
- typeof schema.$ref === "string"
435
- ? localSchemaRef(root, schema.$ref)
436
- : undefined;
437
- if (target === undefined) complete = false;
438
- else {
439
- enqueue({
440
- schema: target,
441
- prefix: item.prefix,
442
- depth: item.depth + 1,
443
- ancestors,
444
- });
445
- }
446
- }
447
-
448
- for (const keyword of ["allOf", "anyOf", "oneOf"]) {
449
- const variants = schema[keyword];
450
- if (!Array.isArray(variants)) continue;
451
- recognized = true;
452
- // Combining schemas can close or conditionally expose fields in ways
453
- // this compact recovery walker intentionally does not prove.
454
- complete = false;
455
- for (const variant of variants) {
456
- if (
457
- !enqueue({
458
- schema: variant,
459
- prefix: item.prefix,
460
- depth: item.depth + 1,
461
- ancestors,
462
- })
463
- ) {
464
- break;
465
- }
466
- }
467
- }
468
- if (truncated) break;
469
-
470
- const properties = schema.properties;
471
- const propertyRecord =
472
- properties !== null &&
473
- typeof properties === "object" &&
474
- !Array.isArray(properties)
475
- ? (properties as Record<string, unknown>)
476
- : undefined;
477
- if (properties !== undefined && propertyRecord === undefined) {
478
- complete = false;
479
- }
480
- const objectShape =
481
- hasSchemaType(schema, "object") || propertyRecord !== undefined;
482
- if (objectShape) {
483
- recognized = true;
484
- const patterns = schema.patternProperties;
485
- let hasPatterns = false;
486
- if (
487
- patterns !== undefined &&
488
- (patterns === null ||
489
- typeof patterns !== "object" ||
490
- Array.isArray(patterns))
491
- ) {
492
- complete = false;
493
- } else if (patterns !== undefined) {
494
- for (const key in patterns as Record<string, unknown>) {
495
- if (Object.prototype.hasOwnProperty.call(patterns, key)) {
496
- hasPatterns = true;
497
- break;
498
- }
499
- }
500
- }
501
- if (schema.additionalProperties !== false || hasPatterns) {
502
- complete = false;
503
- }
504
- if (propertyRecord) {
505
- for (const key in propertyRecord) {
506
- if (!Object.prototype.hasOwnProperty.call(propertyRecord, key)) {
507
- continue;
508
- }
509
- if (++nodes > MAX_PROJECTION_SCHEMA_NODES) {
510
- complete = false;
511
- truncated = true;
512
- break;
513
- }
514
- if (key.length > MAX_PROJECTION_PATH_CHARS) {
515
- complete = false;
516
- truncated = true;
517
- break;
518
- }
519
- if (!selectableFieldName(key)) {
520
- complete = false;
521
- continue;
522
- }
523
- const child = propertyRecord[key];
524
- // A false property schema forbids the property; advertising its name
525
- // as selectable would turn an impossible value into a valid hint.
526
- if (child === false) continue;
527
- const path = item.prefix ? `${item.prefix}.${key}` : key;
528
- if (!addPath(path)) break;
529
- if (
530
- !enqueue({
531
- schema: child,
532
- prefix: path,
533
- depth: item.depth + 1,
534
- ancestors,
535
- })
536
- ) {
537
- break;
538
- }
539
- }
540
- }
541
- }
542
- if (truncated) break;
543
-
544
- const arrayShape =
545
- hasSchemaType(schema, "array") ||
546
- schema.items !== undefined ||
547
- schema.prefixItems !== undefined;
548
- if (arrayShape) {
549
- recognized = true;
550
- const arrayPath = `${item.prefix}[]`;
551
- if (addPath(arrayPath)) {
552
- if (Array.isArray(schema.prefixItems)) {
553
- complete = false;
554
- for (const child of schema.prefixItems) {
555
- if (
556
- !enqueue({
557
- schema: child,
558
- prefix: arrayPath,
559
- depth: item.depth + 1,
560
- ancestors,
561
- })
562
- ) {
563
- break;
564
- }
565
- }
566
- }
567
- if (schema.items === undefined) {
568
- if (!Array.isArray(schema.prefixItems)) complete = false;
569
- } else if (schema.items === true || Array.isArray(schema.items)) {
570
- complete = false;
571
- const children = Array.isArray(schema.items)
572
- ? schema.items
573
- : [];
574
- for (const child of children) {
575
- if (
576
- !enqueue({
577
- schema: child,
578
- prefix: arrayPath,
579
- depth: item.depth + 1,
580
- ancestors,
581
- })
582
- ) {
583
- break;
584
- }
585
- }
586
- } else if (schema.items !== false) {
587
- enqueue({
588
- schema: schema.items,
589
- prefix: arrayPath,
590
- depth: item.depth + 1,
591
- ancestors,
592
- });
593
- }
594
- }
595
- }
596
-
597
- const types = Array.isArray(schema.type)
598
- ? schema.type
599
- : schema.type === undefined
600
- ? []
601
- : [schema.type];
602
- const primitiveOnly =
603
- types.length > 0 &&
604
- types.every(
605
- (type) =>
606
- type === "string" ||
607
- type === "number" ||
608
- type === "integer" ||
609
- type === "boolean" ||
610
- type === "null",
611
- );
612
- if (!recognized && !primitiveOnly) complete = false;
613
- if (truncated) break;
614
- }
615
-
616
- return {
617
- paths: [...paths].sort(),
618
- complete,
619
- truncated,
620
- };
621
- }
622
-
623
- function schemaProjectionFeedback(
624
- outputSchema: unknown,
625
- unmatchedFields: string[],
626
- ): Omit<ProjectionFeedback["$connecta"], "type" | "unmatchedFields"> {
627
- const analysis = analyzeSchemaFields(outputSchema);
628
- const available = new Set(analysis.paths);
629
- const invalidFields = analysis.complete
630
- ? unmatchedFields.filter((field) => !available.has(field))
631
- : [];
632
- const missingArrayMarker = unmatchedFields.some((field) =>
633
- analysis.paths.some(
634
- (availableField) =>
635
- availableField.includes("[]") &&
636
- availableField.replaceAll("[]", "") === field,
637
- ),
638
- );
639
- return {
640
- ...(missingArrayMarker
641
- ? {
642
- hint:
643
- 'Traverse arrays with [] after the array field name, for example "results[].id".',
644
- }
645
- : {}),
646
- schemaDeclared: true,
647
- schemaCoverage: analysis.complete ? "complete" : "partial",
648
- ...(invalidFields.length > 0 ? { invalidFields } : {}),
649
- ...(analysis.paths.length > 0
650
- ? {
651
- availableFields: analysis.paths.slice(
652
- 0,
653
- MAX_PROJECTION_AVAILABLE_FIELDS,
654
- ),
655
- }
656
- : {}),
657
- ...(analysis.truncated ||
658
- analysis.paths.length > MAX_PROJECTION_AVAILABLE_FIELDS
659
- ? { availableFieldsTruncated: true as const }
660
- : {}),
661
- };
662
- }
663
-
664
- /** Select the given dot-paths, retaining both matches and exact misses. */
665
- function applyFields(
666
- value: unknown,
667
- fields: string[],
668
- ): FieldProjection {
669
- const out: Record<string, unknown> = {};
670
- const unmatchedFields: string[] = [];
671
- for (const path of fields) {
672
- const resolved = resolvePath(value, path.split("."));
673
- if (resolved === undefined) unmatchedFields.push(path);
674
- else out[path] = resolved;
675
- }
676
- return { data: out, unmatchedFields };
677
- }
678
-
679
- /**
680
- * Keep the historical flat projection when every path resolves. A miss gets a
681
- * wrapper with a reserved discriminator so neither `{}` nor downstream fields
682
- * named `data` / `projection` can be mistaken for projection feedback.
683
- */
684
- function projectionValue(
685
- value: unknown,
686
- fields: string[],
687
- outputSchema?: unknown,
688
- ): Record<string, unknown> | ProjectionFeedback {
689
- const projected = applyFields(value, fields);
690
- // `$connecta` is reserved at the top level of a projection. Even a fully
691
- // matched downstream field with that exact name is escaped below `data`, so
692
- // no user-controlled value can impersonate Connecta's discriminator.
693
- const reservedCollision = Object.prototype.hasOwnProperty.call(
694
- projected.data,
695
- "$connecta",
696
- );
697
- if (projected.unmatchedFields.length === 0 && !reservedCollision) {
698
- return projected.data;
699
- }
700
- return {
701
- data: projected.data,
702
- $connecta: {
703
- type: "field_projection",
704
- unmatchedFields: projected.unmatchedFields,
705
- ...(outputSchema
706
- ? schemaProjectionFeedback(outputSchema, projected.unmatchedFields)
707
- : {}),
708
- },
709
- };
710
- }
711
-
712
- /** Apply fields to each JSON-parseable text block; non-JSON blocks pass through. */
713
- function applyFieldsToContent(
714
- content: TextContent[],
715
- fields: string[],
716
- outputSchema?: unknown,
717
- ): TextContent[] {
718
- return content.map((b) => {
719
- if (b.type !== "text") return b;
720
- let parsed: unknown;
721
- try {
722
- parsed = JSON.parse(b.text);
723
- } catch {
724
- return b;
725
- }
726
- return {
727
- ...b,
728
- text: JSON.stringify(projectionValue(parsed, fields, outputSchema)),
729
- };
730
- });
731
- }
732
-
733
- // --- result-size guard + get_result (feature 1) ---------------------------
734
-
735
- /**
736
- * The one serialization every result guard measures, stashes, and pages: JSON
737
- * text for whatever JSON can represent, and `String(value)` for the returns
738
- * JSON renders as `undefined` — a handler that returns nothing, a function, or
739
- * a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
740
- * returning `undefined` for those, which is how a handler returning `undefined`
741
- * reached clients as a `{"type":"text"}` block carrying no `text` at all: the
742
- * size guard measured `enc.encode(undefined)` — the empty string, per the
743
- * WebIDL default — and emitted the non-string unchanged (issue #42). `null`
744
- * needs no special case; JSON renders it as `"null"`.
745
- *
746
- * Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
747
- * the three give one answer to the same question. A value JSON cannot serialize
748
- * at all (a BigInt) still throws, as before, and is reported as a failure.
749
- */
750
- function serializeResultText(value: unknown): string {
751
- const serialized = JSON.stringify(value);
752
- return serialized === undefined ? String(value) : serialized;
753
- }
754
-
755
- /**
756
- * Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
757
- * truncation notice every over-cap path hands back.
758
- */
759
- async function stashResult(
760
- text: string,
761
- results: KVStorage,
762
- totalBytes: number,
763
- ): Promise<{
764
- truncated: true;
765
- resultId: string;
766
- totalBytes: number;
767
- hint: string;
768
- nextAction: {
769
- tool: "get_result";
770
- arguments: { id: string; offset: 0 };
771
- };
772
- }> {
773
- const id = crypto.randomUUID();
774
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
775
- return {
776
- truncated: true,
777
- resultId: id,
778
- totalBytes,
779
- hint: "use get_result {id, offset} to page, or re-call with fields to select less",
780
- nextAction: {
781
- tool: "get_result",
782
- arguments: { id, offset: 0 },
783
- },
784
- };
785
- }
786
-
787
- interface GuardedResult<T> {
788
- result: T;
789
- truncated: boolean;
790
- }
791
-
792
- /**
793
- * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
794
- * full text and return the first `cap` bytes followed by a JSON truncation
795
- * notice pointing at get_result. `bytes` is `text` already encoded, so a caller
796
- * that had to measure it to make this decision doesn't encode it twice.
797
- */
798
- async function guardEncoded(
799
- text: string,
800
- bytes: Uint8Array,
801
- results: KVStorage,
802
- cap: number,
803
- ): Promise<GuardedResult<ToolResult>> {
804
- if (bytes.length <= cap) {
805
- return {
806
- result: { content: [{ type: "text", text }] },
807
- truncated: false,
808
- };
809
- }
810
- const notice = await stashResult(text, results, bytes.length);
811
- const head = dec.decode(
812
- bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)),
813
- );
814
- return {
815
- result: {
816
- content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
817
- },
818
- truncated: true,
819
- };
820
- }
821
-
822
- /** {@link guardEncoded} over a string that has not been measured yet. */
823
- async function guardText(
824
- text: string,
825
- results: KVStorage,
826
- cap: number,
827
- ): Promise<GuardedResult<ToolResult>> {
828
- // `JSON.stringify`'s type says `string` where its behavior says `string |
829
- // undefined`, so TypeScript alone does not keep a non-string out of here.
830
- // Normalizing at the door means the size check below always measures exactly
831
- // the text that is emitted, and no future caller can launder a non-string
832
- // through it the way issue #42 describes.
833
- const body: string =
834
- typeof text === "string" ? text : serializeResultText(text);
835
- return guardEncoded(body, enc.encode(body), results, cap);
836
- }
837
-
838
- /** Store an oversized JSON value and replace it with a page handle. */
839
- async function guardValue(
840
- value: unknown,
841
- results: KVStorage,
842
- cap: number,
843
- ): Promise<GuardedResult<unknown>> {
844
- const text = serializeResultText(value);
845
- const bytes = enc.encode(text);
846
- if (bytes.length <= cap) return { result: value, truncated: false };
847
- return {
848
- result: await stashResult(text, results, bytes.length),
849
- truncated: true,
850
- };
851
- }
852
-
853
- /**
854
- * Bound a downstream MCP `content` array by `cap`, measuring the serialized
855
- * envelope — the same string that gets stashed and paged, and the one that
856
- * counts every block rather than only the text ones.
857
- *
858
- * Both halves matter (issue #43). Measuring only text blocks meant an oversized
859
- * all-image result scored zero bytes and was returned inline unbounded, with no
860
- * `resultId` to page from; and measuring one string while truncating another
861
- * left `totalBytes` and the served head describing something the cap was never
862
- * compared against.
863
- *
864
- * Over the cap, what a client gets depends on whether a prefix is usable. An
865
- * all-text envelope keeps the historical head + notice — a JSON prefix is still
866
- * readable. An envelope carrying non-text blocks is replaced by the notice
867
- * alone: the head of a half-written base64 image is of no use to anyone, and
868
- * cutting one leaves unparseable block structure behind. Either way the full
869
- * envelope is stashed and pages through `get_result`.
870
- */
871
- async function guardContent(
872
- content: TextContent[],
873
- results: KVStorage,
874
- cap: number,
875
- ): Promise<GuardedResult<ToolResult>> {
876
- let text: string;
877
- try {
878
- text = JSON.stringify(content);
879
- } catch {
880
- // A block carrying a BigInt or a cycle cannot be serialized, so it cannot
881
- // be measured, stashed, or paged either — there is nothing this guard could
882
- // do with it. Pass it through as the old text-only measure did, rather than
883
- // turning a call that used to succeed into result_processing_failed.
884
- return { result: { content }, truncated: false };
885
- }
886
- const bytes = enc.encode(text);
887
- // Under the cap the downstream blocks pass through untouched, non-text ones
888
- // included, in their original order.
889
- if (bytes.length <= cap) {
890
- return { result: { content }, truncated: false };
891
- }
892
- if (content.every((b) => b.type === "text")) {
893
- return guardEncoded(text, bytes, results, cap);
894
- }
895
- const notice = await stashResult(text, results, bytes.length);
896
- return {
897
- result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
898
- truncated: true,
899
- };
900
- }
901
-
902
- // --- compact schema rendering (feature 3a) --------------------------------
903
-
904
- // --- argument shapes -------------------------------------------------------
905
-
906
- export interface SearchArgs {
907
- query?: string;
908
- connector?: string;
909
- safety?: "readOnly" | "approvalRequired" | "all";
910
- limit?: number;
911
- offset?: number;
912
- fullDescriptions?: boolean;
913
- includeSchemas?: "compact" | "json";
914
- }
915
- type ResultMode = "mcp" | "value";
916
- export interface CallArgs {
917
- address: string;
918
- args?: Record<string, unknown>;
919
- fields?: string[];
920
- resultMode?: ResultMode;
921
- timeoutMs?: number;
922
- /** Retries after the first attempt; honored only for safely annotated tools. */
923
- maxRetries?: number;
924
- /** Include connector/catalog/result-processing timing segments. */
925
- diagnostics?: boolean;
926
- }
927
- export interface DestructiveCallArgs extends CallArgs {
928
- /** Short model-authored context for the host's approval UI; never downstream input. */
929
- reason?: string;
930
- }
931
- export interface GetResultArgs {
932
- id: string;
933
- /**
934
- * Byte offset to page from; a whole number >= 0, aligned back to the nearest
935
- * character boundary and reported as the response's `offset`. Defaults to 0.
936
- */
937
- offset?: number;
938
- /** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
939
- maxBytes?: number;
940
- }
941
- export interface AuthorizeArgs {
942
- connector: string;
943
- force?: boolean;
944
- }
945
- export interface SkillArgs {
946
- name?: string;
947
- }
948
-
949
- /**
950
- * The sentence that closes the OAuth handoff, telling the operator's agent how
951
- * to confirm the flow landed through the one surface it can call.
952
- */
953
- function oauthFollowUp(connectorId: string): string {
954
- return `Then retry the original call; connecta.search({ connector: ${JSON.stringify(connectorId)} }) inside execute_code confirms the catalog now loads.`;
955
- }
956
-
957
- /**
958
- * Every meta-tool handler over a registry, one per registered tool. Exported for
959
- * direct testing; registerMetaTools() wires the six explicit tools onto an
960
- * McpServer. `opts.defaultToolTimeoutMs` supplies a deadline for calls that
961
- * don't carry one. (execute_code is registered separately by
962
- * registerExecuteTool, and builds its own services over the same registry.)
963
- *
964
- * What execute_code shares with these handlers is the services layer beneath
965
- * them — `CatalogService` and `InvocationService` — not the handlers, which no
966
- * in-program path calls.
967
- *
968
- * Deployment-wide result-size caps are read off the registry view rather than
969
- * passed in: `ConnectaConfig.calls.maxResultBytes` and its per-connector
970
- * override each have one runtime source of truth.
971
- */
972
- export function createMetaTools(
973
- registry: RegistryView,
974
- baseUrl: string,
975
- opts: {
976
- /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
977
- defaultToolTimeoutMs?: number;
978
- /** Per-connector deadline for the search/describe probe fan-out. Default 30_000. */
979
- probeTimeoutMs?: number;
980
- /** Maximum simultaneous connector discovery operations. Default 4. */
981
- discoveryConcurrency?: number;
982
- activity?: ActivityRequestContext;
983
- /** Inbound request cancellation shared by every call this request makes. */
984
- requestSignal?: AbortSignal;
985
- } = {},
986
- ) {
987
- // Already normalized and warned about at registry construction.
988
- const globalCap = registry.maxResultBytes;
989
- const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
990
- const probeTimeoutMs =
991
- normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
992
- const discoveryConcurrency = resolveDiscoveryConcurrency(
993
- opts.discoveryConcurrency,
994
- );
995
- // createMetaTools() is called once per inbound MCP request. Sharing this
996
- // identity lets remote connectors reuse one downstream client inside that
997
- // request without leaking request-bound I/O into the next one.
998
- const requestScope = {};
999
- const catalog = new CatalogService(registry, baseUrl, {
1000
- requestScope,
1001
- probeTimeoutMs,
1002
- concurrency: discoveryConcurrency,
1003
- // searchRoute keeps its top-level default. In-program callers use a
1004
- // separate CatalogService configured for connecta.search.
1005
- });
1006
- const invocation = new InvocationService(registry, catalog, opts.activity);
1007
-
1008
- interface RunCallOutcome {
1009
- toolResult: ToolResult;
1010
- durationMs: number;
1011
- attempts: number;
1012
- timing: InvocationTiming;
1013
- value?: unknown;
1014
- error?: CallErrorDetails;
1015
- }
1016
-
1017
- interface ProcessedCallResult {
1018
- toolResult: ToolResult;
1019
- value?: unknown;
1020
- /**
1021
- * Friction on a call that *succeeded*. It travels as a friction class, not
1022
- * as an `errorCode`, so persistence keyed on "this row has an error code"
1023
- * keeps counting failures rather than truncations.
1024
- */
1025
- friction?: "result_too_large";
1026
- }
1027
-
1028
- /** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
1029
- async function runCall(
1030
- call: CallArgs,
1031
- source: ActivityCallSource,
1032
- options: { allowDestructive?: boolean } = {},
1033
- ): Promise<RunCallOutcome> {
1034
- const results = registry.resultsStorage();
1035
- const fields = call.fields && call.fields.length > 0 ? call.fields : null;
1036
- const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
1037
- const outcome = await invocation.invoke<ProcessedCallResult>(
1038
- call.address,
1039
- call.args ?? {},
1040
- {
1041
- source,
1042
- ...(options.allowDestructive !== undefined
1043
- ? { allowDestructive: options.allowDestructive }
1044
- : {}),
1045
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
1046
- ...(call.maxRetries !== undefined
1047
- ? { maxRetries: call.maxRetries }
1048
- : {}),
1049
- ...(opts.requestSignal !== undefined
1050
- ? { requestSignal: opts.requestSignal }
1051
- : {}),
1052
- unwrapResult: call.resultMode === "value",
1053
- processResult: async (result, resolved) => {
1054
- // Result-size cap for THIS call: the connector's own override wins,
1055
- // then the deployment-wide value, then the built-in default (already
1056
- // folded into `globalCap`). Resolved per call so one request can
1057
- // mix a tight-capped connector with siblings on the global cap. An
1058
- // override the registry already warned about at startup is dropped
1059
- // here, so the connector simply inherits `globalCap`.
1060
- const cap = resolveMaxResultBytes(
1061
- resolved.connector.maxResultBytes,
1062
- globalCap,
1063
- );
1064
- if (call.resultMode === "value") {
1065
- let value = fields
1066
- ? projectionValue(
1067
- result,
1068
- fields,
1069
- resolved.definition.outputSchema,
1070
- )
1071
- : result;
1072
- const guarded = await guardValue(value, results, cap);
1073
- value = guarded.result;
1074
- return {
1075
- toolResult: jsonResult({ ok: true, data: value }),
1076
- value,
1077
- ...(guarded.truncated
1078
- ? { friction: "result_too_large" as const }
1079
- : {}),
1080
- };
1081
- }
1082
- if (resolved.connector.kind === "mcp") {
1083
- const mcpResult = result as { content?: TextContent[] };
1084
- let content = mcpResult?.content ?? [];
1085
- if (fields) {
1086
- content = applyFieldsToContent(
1087
- content,
1088
- fields,
1089
- resolved.definition.outputSchema,
1090
- );
1091
- }
1092
- const guarded = await guardContent(content, results, cap);
1093
- return {
1094
- toolResult: guarded.result,
1095
- ...(guarded.truncated
1096
- ? { friction: "result_too_large" as const }
1097
- : {}),
1098
- };
1099
- }
1100
- const value = fields
1101
- ? projectionValue(
1102
- result,
1103
- fields,
1104
- resolved.definition.outputSchema,
1105
- )
1106
- : result;
1107
- const guarded = await guardText(
1108
- serializeResultText(value),
1109
- results,
1110
- cap,
1111
- );
1112
- return {
1113
- toolResult: guarded.result,
1114
- value,
1115
- ...(guarded.truncated
1116
- ? { friction: "result_too_large" as const }
1117
- : {}),
1118
- };
1119
- },
1120
- activityFriction: (processed) => processed.friction,
1121
- },
1122
- );
1123
- if (!outcome.ok) {
1124
- const structuredRecovery = outcome.error.nextAction !== undefined;
1125
- const failedResult =
1126
- structuredRecovery ||
1127
- outcome.error.code === "auth_required" ||
1128
- outcome.error.code === "invalid_args" ||
1129
- outcome.error.code === "input_required_unsupported" ||
1130
- call.resultMode === "value"
1131
- ? jsonResult({
1132
- ok: false,
1133
- error: outcome.error,
1134
- durationMs: outcome.durationMs,
1135
- attempts: outcome.attempts,
1136
- ...(call.diagnostics ? { timing: outcome.timing } : {}),
1137
- })
1138
- : errorResult(outcome.error.message);
1139
- if (
1140
- structuredRecovery ||
1141
- outcome.error.code === "auth_required" ||
1142
- outcome.error.code === "invalid_args" ||
1143
- outcome.error.code === "input_required_unsupported"
1144
- ) {
1145
- failedResult.isError = true;
1146
- }
1147
- return {
1148
- toolResult: failedResult,
1149
- durationMs: outcome.durationMs,
1150
- attempts: outcome.attempts,
1151
- timing: outcome.timing,
1152
- error: outcome.error,
1153
- };
1154
- }
1155
- const valueModeResult =
1156
- call.resultMode === "value"
1157
- ? jsonResult({
1158
- ok: true,
1159
- data: outcome.value.value,
1160
- durationMs: outcome.durationMs,
1161
- attempts: outcome.attempts,
1162
- ...(call.diagnostics ? { timing: outcome.timing } : {}),
1163
- })
1164
- : outcome.value.toolResult;
1165
- return {
1166
- toolResult: valueModeResult,
1167
- durationMs: outcome.durationMs,
1168
- attempts: outcome.attempts,
1169
- timing: outcome.timing,
1170
- ...(Object.prototype.hasOwnProperty.call(outcome.value, "value")
1171
- ? { value: outcome.value.value }
1172
- : {}),
1173
- };
1174
- }
1175
-
1176
- return {
1177
- async skills(args: SkillArgs = {}): Promise<ToolResult> {
1178
- const connectors = registry.listConnectors();
1179
- if (!args.name) {
1180
- return {
1181
- content: [
1182
- {
1183
- type: "text",
1184
- text:
1185
- 'Available skills. Fetch one with skills({ name: "<name>" }).\n\n' +
1186
- listSkills(connectors)
1187
- .map((skill) => `- \`${skill.name}\` — ${skill.description}`)
1188
- .join("\n"),
1189
- },
1190
- ],
1191
- };
1192
- }
1193
- const skill = resolveSkill(args.name, connectors);
1194
- if (!skill.found) return errorResult(skill.message);
1195
- return { content: [{ type: "text", text: skill.content }] };
1196
- },
1197
-
1198
- async searchTools(args: SearchArgs): Promise<ToolResult> {
1199
- return discoveryResult(
1200
- async () =>
1201
- groupedSearchResult(
1202
- await catalog.search({
1203
- ...args,
1204
- includeSchemaKeys: args.includeSchemas !== undefined,
1205
- }),
1206
- ),
1207
- "Request a smaller limit, omit fullDescriptions, or use compact schemas.",
1208
- );
1209
- },
1210
-
1211
- async callTool(args: CallArgs): Promise<ToolResult> {
1212
- return (await runCall(args, "call_tool")).toolResult;
1213
- },
1214
-
1215
- async callDestructiveTool(args: DestructiveCallArgs): Promise<ToolResult> {
1216
- // `reason` is read by the host's approval view and stops there — runCall
1217
- // forwards only the call fields, so it never reaches the connector.
1218
- return (
1219
- await runCall(args, "call_destructive_tool", { allowDestructive: true })
1220
- ).toolResult;
1221
- },
1222
-
1223
- async getResult(args: GetResultArgs): Promise<ToolResult> {
1224
- // Client-supplied page size and offset: normal input-validation errors,
1225
- // not clamps. Callers arriving over MCP are rejected earlier by the
1226
- // registered zod schema and never reach these branches, so they exist for
1227
- // in-process callers of createMetaTools — which have no schema in front
1228
- // of them — and to keep the rules true of the handler on its own terms.
1229
- if (
1230
- args.maxBytes !== undefined &&
1231
- !isValidMaxResultBytes(args.maxBytes)
1232
- ) {
1233
- return errorResult(
1234
- `Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
1235
- `>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`,
1236
- );
1237
- }
1238
- if (args.offset !== undefined && !isValidResultOffset(args.offset)) {
1239
- return errorResult(
1240
- `Invalid offset ${args.offset}: must be a whole number of bytes ` +
1241
- `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`,
1242
- );
1243
- }
1244
- const results = registry.resultsStorage();
1245
- const stored = await results.get(`result:${args.id}`);
1246
- if (stored === null || stored === undefined) {
1247
- return errorResult(`Unknown or expired result id "${args.id}"`);
1248
- }
1249
- const bytes = enc.encode(stored);
1250
- const total = bytes.length;
1251
- // Validated above, so no coercion is needed here — only alignment. A
1252
- // client that computes its own offsets can land inside a multi-byte
1253
- // character, which would decode as U+FFFD; the offset actually served is
1254
- // the boundary at or before it, and it is what the response reports back
1255
- // as `offset` (issue #38).
1256
- const offset = alignStartToCharBoundary(bytes, args.offset ?? 0);
1257
- // Page size only: a stashed result carries no connector identity, so
1258
- // get_result keeps the deployment-wide default when none is requested.
1259
- // Both sides are validated by now — the argument above, `globalCap` at
1260
- // intake — so `offset + maxBytes` always reaches past `offset`.
1261
- const maxBytes = args.maxBytes ?? globalCap;
1262
- // Align the slice end to a codepoint boundary so a multi-byte char is
1263
- // never split across pages (which would emit U+FFFD on both sides).
1264
- // `nextOffset` is this aligned end, so it is a valid boundary for the
1265
- // next call and paging reassembles the original byte-for-byte.
1266
- const end = alignEndToCharBoundary(
1267
- bytes,
1268
- offset,
1269
- offset + maxBytes,
1270
- total,
1271
- );
1272
- const slice = dec.decode(bytes.slice(offset, end));
1273
- const nextOffset = end < total ? end : undefined;
1274
- return jsonResult({
1275
- offset,
1276
- ...(nextOffset !== undefined ? { nextOffset } : {}),
1277
- totalBytes: total,
1278
- text: slice,
1279
- });
1280
- },
1281
-
1282
- async authorizeConnector(args: AuthorizeArgs): Promise<ToolResult> {
1283
- const connector = registry.getConnector(args.connector);
1284
- if (!connector) {
1285
- return errorResult(`Unknown connector "${args.connector}"`);
1286
- }
1287
- if (!connector.startAuth) {
1288
- if (!connector.credential) {
1289
- return jsonResult({
1290
- connector: connector.id,
1291
- recovery: "unavailable",
1292
- message:
1293
- `Connector "${connector.id}" declares neither downstream OAuth ` +
1294
- "nor an operator-managed credential slot. Update the connector " +
1295
- "or deployment configuration before retrying.",
1296
- });
1297
- }
1298
- const ctx = registry.contextFor(
1299
- connector.id,
1300
- baseUrl,
1301
- requestScope,
1302
- );
1303
- if (!ctx.credential) {
1304
- return jsonResult({
1305
- connector: connector.id,
1306
- recovery: "unavailable",
1307
- message:
1308
- "Credential storage is not configured. Configure " +
1309
- "credentials.encryptionKey, redeploy, then call " +
1310
- "authorize_connector again.",
1311
- });
1312
- }
1313
- const fields = connector.credential.fields?.map((field) => ({
1314
- name: field.name,
1315
- guidance: field.description ?? field.label,
1316
- })) ?? [
1317
- {
1318
- name: "value",
1319
- guidance:
1320
- connector.credential.description ??
1321
- connector.credential.label,
1322
- },
1323
- ];
1324
- return jsonResult({
1325
- connector: connector.id,
1326
- recovery: "operator_config",
1327
- credential: {
1328
- label: connector.credential.label,
1329
- fields,
1330
- },
1331
- operatorUrl: new URL("/credentials", baseUrl).toString(),
1332
- instructions:
1333
- "Have the operator open operatorUrl, set and test the credential, " +
1334
- "then retry the original call. No redeploy is needed. Credential " +
1335
- "mutation requires a Clerk-authenticated operator.",
1336
- });
1337
- }
1338
- const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
1339
- try {
1340
- const status = await connector.startAuth(
1341
- ctx,
1342
- args.force !== undefined ? { force: args.force } : {},
1343
- );
1344
- if (status.state === "auth_required" && !status.authorizationUrl) {
1345
- // auth_required with nothing to open is a dead end for the operator.
1346
- return errorResult(
1347
- `Connector "${connector.id}": authorization required but no URL is available — retry authorize_connector.`,
1348
- );
1349
- }
1350
- return jsonResult({
1351
- connector: connector.id,
1352
- recovery: "oauth",
1353
- status: status.state,
1354
- ...(status.authorizationUrl
1355
- ? {
1356
- authorizationUrl: status.authorizationUrl,
1357
- instructions:
1358
- "Have the operator open authorizationUrl in a browser and complete the consent flow. The provider then redirects back to this server's /oauth/callback/<connector> route, which finishes the flow automatically. " +
1359
- oauthFollowUp(connector.id),
1360
- }
1361
- : {}),
1362
- ...(status.message ? { message: status.message } : {}),
1363
- });
1364
- } catch (err) {
1365
- return errorResult(msg(err));
1366
- } finally {
1367
- // Auth state may have changed — even on a throw or a half-wiped force —
1368
- // so don't serve a stale tool list.
1369
- await registry.invalidateStored(connector.id);
1370
- }
1371
- },
1372
- };
1373
- }
1374
-
1375
- const SEARCH_DESC = `Use top-level search only for exactly one unreduced read, then call_tool, or for write-capable work, then call_destructive_tool. For read-only reduction, dependent or multiple calls, never search here: make one execute_code program that searches and calls. Use 2–4 distinctive action/object terms, not the full request; set connector to the obvious integration id to load one catalog instead of all; omit limit initially (default ${DEFAULT_SEARCH_LIMIT}), page to ${MAX_SEARCH_LIMIT} if needed. safety="readOnly" returns only calls available to call_tool/code; "approvalRequired" returns the rest; omitted/"all" returns all. This filters results, not authority. includeSchemas="compact" adds the input and any declared output shape, bounded; plain objects expose inputKeys, requiredInputKeys, and outputKeys; truncation flags mark incomplete shapes; matches also carry declared annotations. Require purpose/address fit plus compatible inputs, truncation, safety, and outputs — never the first lexical match. Empty or whitespace-only query browses all; non-empty input with no ASCII terms returns no match.`;
1376
- const CALL_DESC =
1377
- 'Use for ONE tool explicitly annotated readOnlyHint: true — the cheapest path for a single cold call. For two or more calls, dependent steps, loops, joins, or data reduction use execute_code, whose connecta.call and connecta.batch reach the same tools. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; traverse arrays with [] (for example results[].id). Misses return data plus `$connecta` feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
1378
- const CALL_DESTRUCTIVE_DESC =
1379
- "Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. Include a short reason explaining the intended consequence for the human reviewer; it grants no authority and is never passed downstream. The MCP destructiveHint on this meta-tool lets the host request human approval before execution. Use only after reviewing the downstream tool schema and consequences.";
1380
- const GET_RESULT_DESC =
1381
- "Page a truncated result stashed by call_tool or call_destructive_tool; a program's oversized return is not paged, so reduce it in code instead. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
1382
- const AUTHORIZE_DESC =
1383
- "Use after auth_required. Returns an OAuth or operator-credential handoff, or reports required deployment configuration. force=true restarts OAuth only; this tool never accepts credentials.";
1384
- const SKILLS_DESC =
1385
- 'List or fetch concise guidance for choosing among Connecta meta-tools. Call skills({ name: "usage" }) once when the routing workflow is unfamiliar; do not refetch it in the same task.';
1386
-
1387
- const SEARCH_WITH_DESCRIBE_DESC = `${SEARCH_DESC} Expand an ambiguous compact shape, or read exact JSON constraints, with connecta.describe inside execute_code.`;
1388
-
1389
- /**
1390
- * Sentences appended to a meta-tool description only when this connection
1391
- * actually has connector guides. Tool descriptions are always-loaded context,
1392
- * so a deployment with no guides gets every base description unchanged rather
1393
- * than paying for text about a feature it does not use.
1394
- *
1395
- * Registration is per connection and reads the configured connector set.
1396
- */
1397
- const GUIDE_NOTES = {
1398
- skills:
1399
- " skills({}) also lists this deployment's scoped connector guides; fetch only an exact name listed there or carried by discovery, never one inferred from a connector id.",
1400
- search:
1401
- " A result carrying `guide` also carries a bounded `guideSummary`. `guideRequired: true` is a hard stop: fetch that exact guide before calling. `guideRequiredReasons` explains why — `connector_required` and `approval_required` stand however you expand the schema; `schema_truncated` clears once describe returns the exact one. Otherwise fetch only when the summary names a connector convention relevant to the task. A complete, unambiguous read-only schema needs no otherwise-irrelevant guide fetch.",
1402
- destructive:
1403
- " Before a consequential call, inspect the address through discovery or describe and fetch any connector guide it names.",
1404
- } as const;
1405
-
1406
- /** `base`, plus its guide note when any VISIBLE connector carries a guide. */
1407
- function describedFor(
1408
- registry: RegistryView,
1409
- base: string,
1410
- note: keyof typeof GUIDE_NOTES,
1411
- ): string {
1412
- return hasConnectorGuides(registry.listConnectors())
1413
- ? base + GUIDE_NOTES[note]
1414
- : base;
1415
- }
1416
-
1417
- /**
1418
- * Connecta refuses downstream tools that are not explicitly annotated
1419
- * read-only, so its own meta-tools must carry the same hints — otherwise a
1420
- * host that gates on annotations prompts for every search, and a connecta
1421
- * aggregated behind another connecta would be refused by its own policy.
1422
- */
1423
- const READ_ONLY_REMOTE = {
1424
- readOnlyHint: true,
1425
- destructiveHint: false,
1426
- openWorldHint: true,
1427
- } as const;
1428
-
1429
- /** Read-only and served entirely from connecta's own storage. */
1430
- const READ_ONLY_LOCAL = {
1431
- readOnlyHint: true,
1432
- destructiveHint: false,
1433
- idempotentHint: true,
1434
- openWorldHint: false,
1435
- } as const;
1436
-
1437
- const CALL_INPUT_SCHEMA = {
1438
- address: z.string(),
1439
- args: z.record(z.string(), z.unknown()).optional(),
1440
- fields: z.array(z.string()).optional(),
1441
- resultMode: z.enum(["mcp", "value"]).optional(),
1442
- timeoutMs: z.number().int().positive().optional(),
1443
- maxRetries: z.number().int().min(0).max(2).optional(),
1444
- diagnostics: z.boolean().optional(),
1445
- };
1446
-
1447
- /**
1448
- * Register the six explicit meta-tools onto an McpServer instance.
1449
- * `registerExecuteTool` adds the seventh, `execute_code`. Broad discovery and
1450
- * multi-call work is reached through `connecta.search` / `connecta.describe` /
1451
- * `connecta.batch` inside a program, which `execute_code` builds over the same
1452
- * `CatalogService` and `InvocationService` these handlers use — one shared
1453
- * services layer, two adapters above it.
1454
- */
1455
- export function registerMetaTools(
1456
- server: McpServer,
1457
- registry: RegistryView,
1458
- ctx: {
1459
- baseUrl: string;
1460
- defaultToolTimeoutMs?: number;
1461
- probeTimeoutMs?: number;
1462
- discoveryConcurrency?: number;
1463
- activity?: ActivityRequestContext;
1464
- requestSignal?: AbortSignal;
1465
- },
1466
- ): void {
1467
- const mt = createMetaTools(registry, ctx.baseUrl, {
1468
- ...(ctx.defaultToolTimeoutMs !== undefined
1469
- ? { defaultToolTimeoutMs: ctx.defaultToolTimeoutMs }
1470
- : {}),
1471
- ...(ctx.probeTimeoutMs !== undefined
1472
- ? { probeTimeoutMs: ctx.probeTimeoutMs }
1473
- : {}),
1474
- ...(ctx.discoveryConcurrency !== undefined
1475
- ? { discoveryConcurrency: ctx.discoveryConcurrency }
1476
- : {}),
1477
- ...(ctx.activity !== undefined ? { activity: ctx.activity } : {}),
1478
- ...(ctx.requestSignal !== undefined
1479
- ? { requestSignal: ctx.requestSignal }
1480
- : {}),
1481
- });
1482
-
1483
- server.registerTool(
1484
- "skills",
1485
- {
1486
- description: describedFor(registry, SKILLS_DESC, "skills"),
1487
- inputSchema: z.object({ name: z.string().optional() }),
1488
- annotations: READ_ONLY_LOCAL,
1489
- _meta: { ui: { visibility: ["model"] } },
1490
- },
1491
- async (args) => mt.skills(args as SkillArgs),
1492
- );
1493
-
1494
- server.registerTool(
1495
- "search_tools",
1496
- {
1497
- description: describedFor(
1498
- registry,
1499
- SEARCH_WITH_DESCRIBE_DESC,
1500
- "search",
1501
- ),
1502
- inputSchema: z.object({
1503
- query: z.string().optional(),
1504
- connector: z.string().optional(),
1505
- safety: z
1506
- .enum(["readOnly", "approvalRequired", "all"])
1507
- .optional(),
1508
- limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
1509
- offset: z.number().int().nonnegative().optional(),
1510
- fullDescriptions: z.boolean().optional(),
1511
- includeSchemas: z.enum(["compact", "json"]).optional(),
1512
- }),
1513
- annotations: READ_ONLY_REMOTE,
1514
- _meta: { ui: { visibility: ["model"] } },
1515
- },
1516
- async (args) => mt.searchTools(args as SearchArgs),
1517
- );
1518
-
1519
- server.registerTool(
1520
- "call_tool",
1521
- {
1522
- description: CALL_DESC,
1523
- inputSchema: z.object(CALL_INPUT_SCHEMA),
1524
- // call_tool admits only tools that are themselves explicitly read-only;
1525
- // anything else is refused and routed to call_destructive_tool.
1526
- annotations: READ_ONLY_REMOTE,
1527
- // The trusted program-view shell delegates bounded named reads here.
1528
- // It is already one of the seven model tools; app visibility adds no
1529
- // tool and this handler repeats ordinary fail-closed read admission.
1530
- _meta: { ui: { visibility: ["model", "app"] } },
1531
- },
1532
- async (args) => mt.callTool(args as CallArgs),
1533
- );
1534
-
1535
- server.registerTool(
1536
- "call_destructive_tool",
1537
- {
1538
- description: describedFor(
1539
- registry,
1540
- CALL_DESTRUCTIVE_DESC,
1541
- "destructive",
1542
- ),
1543
- inputSchema: z.object({
1544
- ...CALL_INPUT_SCHEMA,
1545
- // Bounded above, but with no lower bound: a model that sends `""` or
1546
- // whitespace has written no reason, and failing an entire consequential
1547
- // call over a cosmetic field the host merely displays is the wrong
1548
- // trade. It is normalized to absent below instead.
1549
- reason: z.string().max(500).optional(),
1550
- }),
1551
- annotations: {
1552
- destructiveHint: true,
1553
- readOnlyHint: false,
1554
- openWorldHint: true,
1555
- },
1556
- _meta: { ui: { visibility: ["model"] } },
1557
- },
1558
- async (args) => {
1559
- // `reason` is the host's to display and connecta's to keep out of the
1560
- // downstream call, so this destructuring is the whole of its handling:
1561
- // nothing below reads it. Dropping it is also what makes an empty or
1562
- // whitespace-only one "absent" rather than a validation failure — there
1563
- // is no field left for it to be absent from.
1564
- const { reason: _hostContext, ...call } = args as DestructiveCallArgs;
1565
- return mt.callDestructiveTool(call);
1566
- },
1567
- );
1568
-
1569
- server.registerTool(
1570
- "authorize_connector",
1571
- {
1572
- description: AUTHORIZE_DESC,
1573
- inputSchema: z.object({
1574
- connector: z.string(),
1575
- force: z.boolean().optional(),
1576
- }),
1577
- // Starts (or with force, resets) a downstream OAuth flow — it changes
1578
- // stored connector auth state, so it is deliberately not read-only.
1579
- annotations: {
1580
- readOnlyHint: false,
1581
- destructiveHint: false,
1582
- openWorldHint: true,
1583
- },
1584
- _meta: { ui: { visibility: ["model"] } },
1585
- },
1586
- async (args) => mt.authorizeConnector(args as AuthorizeArgs),
1587
- );
1588
-
1589
- server.registerTool(
1590
- "get_result",
1591
- {
1592
- description: GET_RESULT_DESC,
1593
- inputSchema: z.object({
1594
- id: z.string(),
1595
- // Both bounds are the shared rules (isValidResultOffset,
1596
- // isValidMaxResultBytes) expressed for the wire: spelling them against
1597
- // the same constants keeps the schema from drifting away from the
1598
- // in-handler checks if either floor ever moves.
1599
- offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
1600
- maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
1601
- }),
1602
- annotations: READ_ONLY_LOCAL,
1603
- _meta: { ui: { visibility: ["model"] } },
1604
- },
1605
- async (args) => mt.getResult(args as GetResultArgs),
1606
- );
1607
- }