@zackbart/connecta 0.29.0 → 0.31.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 (116) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +126 -134
  3. package/bin/connecta.mjs +13 -2
  4. package/bin/version.mjs +1 -1
  5. package/dist/argument-redaction.js +230 -0
  6. package/dist/auth-elicitation.js +14 -9
  7. package/dist/authorization-handoff.js +111 -0
  8. package/dist/call-recovery.js +25 -0
  9. package/dist/catalog-cache.js +2 -2
  10. package/dist/catalog-service.js +103 -63
  11. package/dist/config-schema.d.ts +14 -7
  12. package/dist/config-schema.js +29 -10
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +12 -7
  15. package/dist/connectors/guarded-fetch.d.ts +9 -1
  16. package/dist/connectors/guarded-fetch.js +17 -5
  17. package/dist/connectors/negotiation-cache.js +1 -1
  18. package/dist/downstream-input-context.js +9 -0
  19. package/dist/downstream-input.js +13 -11
  20. package/dist/errors.d.ts +38 -12
  21. package/dist/errors.js +19 -9
  22. package/dist/execute.js +375 -43
  23. package/dist/executor-result.js +11 -9
  24. package/dist/executors/quickjs-child.js +12 -5
  25. package/dist/executors/quickjs-protocol.js +5 -6
  26. package/dist/executors/quickjs-runtime.js +6 -1
  27. package/dist/executors/quickjs.js +20 -4
  28. package/dist/guest-runtime.js +52 -2
  29. package/dist/guest-types.d.ts +47 -5
  30. package/dist/index.d.ts +1 -1
  31. package/dist/invocation.js +72 -14
  32. package/dist/meta-output.js +0 -15
  33. package/dist/meta-tools.js +166 -95
  34. package/dist/oauth-handoff.js +88 -11
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +9 -0
  37. package/dist/operator-ui/view.js +11 -0
  38. package/dist/program-result.js +5 -0
  39. package/dist/program-writes.js +7 -48
  40. package/dist/provider.d.ts +8 -6
  41. package/dist/provider.js +3 -4
  42. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  43. package/dist/providers/_shared/request-token.js +83 -0
  44. package/dist/providers/_shared/rest/dispatch.js +41 -0
  45. package/dist/providers/_shared/rest/operation-index.d.ts +133 -0
  46. package/dist/providers/_shared/rest/operation-index.js +527 -0
  47. package/dist/providers/_shared/rest/tools.d.ts +141 -0
  48. package/dist/providers/_shared/rest/tools.js +569 -0
  49. package/dist/providers/_shared/rest/value-safety.d.ts +151 -0
  50. package/dist/providers/_shared/rest/value-safety.js +580 -0
  51. package/dist/providers/cloudflare/graphql.js +385 -0
  52. package/dist/providers/cloudflare/index.d.ts +47 -51
  53. package/dist/providers/cloudflare/index.js +188 -1997
  54. package/dist/providers/cloudflare/openapi.generated.js +3347 -0
  55. package/dist/providers/cloudflare/rest.d.ts +37 -0
  56. package/dist/providers/cloudflare/rest.js +1056 -0
  57. package/dist/providers/cloudflare/skill.generated.js +7 -4
  58. package/dist/providers/cloudflare/value-safety.js +1247 -0
  59. package/dist/providers/infisical/index.d.ts +7 -0
  60. package/dist/providers/infisical/index.js +713 -0
  61. package/dist/providers/infisical/skill.generated.js +8 -0
  62. package/dist/providers/notion/index.d.ts +35 -31
  63. package/dist/providers/notion/index.js +60 -1499
  64. package/dist/providers/notion/openapi.generated.js +75 -0
  65. package/dist/providers/notion/rest.d.ts +24 -0
  66. package/dist/providers/notion/rest.js +1067 -0
  67. package/dist/providers/notion/skill.generated.js +5 -4
  68. package/dist/providers/notion/value-safety.js +31 -0
  69. package/dist/providers/stripe/index.d.ts +26 -20
  70. package/dist/providers/stripe/index.js +103 -130
  71. package/dist/providers/stripe/openapi.generated.js +649 -0
  72. package/dist/providers/stripe/rest.d.ts +14 -0
  73. package/dist/providers/stripe/rest.js +395 -0
  74. package/dist/providers/stripe/skill.generated.js +7 -5
  75. package/dist/providers/stripe/value-safety.js +705 -0
  76. package/dist/providers/vercel/index.d.ts +31 -22
  77. package/dist/providers/vercel/index.js +96 -853
  78. package/dist/providers/vercel/openapi.generated.js +456 -0
  79. package/dist/providers/vercel/rest.d.ts +20 -0
  80. package/dist/providers/vercel/rest.js +1002 -0
  81. package/dist/providers/vercel/skill.generated.js +4 -5
  82. package/dist/providers/vercel/value-safety.js +328 -0
  83. package/dist/registry.d.ts +8 -6
  84. package/dist/registry.js +134 -61
  85. package/dist/resource-read.js +4 -1
  86. package/dist/routes/mcp.js +11 -2
  87. package/dist/sent-secrets.d.ts +54 -13
  88. package/dist/sent-secrets.js +770 -231
  89. package/dist/skills.js +26 -16
  90. package/dist/sqlite.js +21 -6
  91. package/dist/storage/keys.js +20 -5
  92. package/dist/storage/memory.js +9 -4
  93. package/dist/storage/sql.js +18 -9
  94. package/dist/types.d.ts +17 -3
  95. package/dist/usage-guide.js +49 -40
  96. package/dist/version.d.ts +1 -1
  97. package/dist/version.js +1 -1
  98. package/dist/worker.js +2 -1
  99. package/dist/write-outcome.js +47 -0
  100. package/documentation/README.md +64 -0
  101. package/documentation/architecture.md +241 -97
  102. package/documentation/auth.md +138 -5
  103. package/documentation/code-mode.md +224 -107
  104. package/documentation/deploying.md +146 -0
  105. package/documentation/integrating.md +170 -0
  106. package/documentation/meta-tools.md +142 -30
  107. package/documentation/operating.md +135 -0
  108. package/documentation/operator-tests.md +4 -0
  109. package/documentation/operator-ui.md +132 -0
  110. package/documentation/provider-auth-migration.md +307 -0
  111. package/documentation/provider-migration-0.29.md +22 -5
  112. package/examples/worker/README.md +9 -2
  113. package/package.json +9 -2
  114. package/templates/node/README.md +9 -2
  115. package/templates/node/package.json +1 -1
  116. package/dist/providers/vercel/mcp-ownership.js +0 -212
@@ -1,3 +1,4 @@
1
+ import { authorizationHandoff, bindAuthorizationHandoffs } from "./authorization-handoff.js";
1
2
  import { bindMcpClient } from "./mcp-client-context.js";
2
3
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
3
4
  import { z } from "zod";
@@ -6,18 +7,20 @@ import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPo
6
7
  import { resolveDiscoveryConcurrency } from "./concurrency.js";
7
8
  import { boundedEchoText, ConnectorCallError, msg } from "./errors.js";
8
9
  import { failureRecord, logFailure } from "./operator-record.js";
9
- import { MAX_EXECUTE_RESULT_CHARS, serializeResultText } from "./executor-result.js";
10
+ import { guardExecuteResultValue, MAX_EXECUTE_RESULT_CHARS, serializeResultText } from "./executor-result.js";
10
11
  import { InvocationService } from "./invocation.js";
11
12
  import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
12
13
  import { hasConnectorGuides, SkillsRegistry } from "./skills.js";
13
14
  import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs } from "./timeout.js";
14
- import { AUTHORIZE_OUTPUT, CALL_OUTPUT, SEARCH_OUTPUT, SKILLS_OUTPUT } from "./meta-output.js";
15
+ import { AUTHORIZE_OUTPUT, SEARCH_OUTPUT, SKILLS_OUTPUT } from "./meta-output.js";
15
16
  import { RESULT_TTL_SECONDS, resultKeys } from "./storage/keys.js";
16
17
  import { agentOutputOperations, sentSecretsForRequest } from "./sent-secrets.js";
17
18
  import { captureDownstreamInput } from "./downstream-input.js";
19
+ import { PROGRAM_RESULT_WIRE_BYTES } from "./program-result.js";
18
20
  export { MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT };
19
21
  const enc = new TextEncoder();
20
- const dec = new TextDecoder();
22
+ // Each page is a slice of payload bytes, so a leading U+FEFF is data.
23
+ const dec = new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
21
24
  export function jsonResult(obj, text = JSON.stringify(obj)) {
22
25
  return {
23
26
  content: [{ type: "text", text }],
@@ -142,6 +145,23 @@ function base64Of(bytes) {
142
145
  }
143
146
  /** Opens a write's notice, ahead of any paging instruction. */
144
147
  const WRITE_ALREADY_RAN = "This write already ran: do not call it again to see its result.";
148
+ /** UTF-8 cannot represent lone UTF-16 surrogates without changing them. */
149
+ function isWellFormedText(text) {
150
+ const native = String.prototype.isWellFormed;
151
+ if (native)
152
+ return native.call(text);
153
+ for (let at = 0; at < text.length; at++) {
154
+ const unit = text.charCodeAt(at);
155
+ if (unit >= 0xd800 && unit <= 0xdbff) {
156
+ const next = text.charCodeAt(++at);
157
+ if (!(next >= 0xdc00 && next <= 0xdfff))
158
+ return false;
159
+ }
160
+ else if (unit >= 0xdc00 && unit <= 0xdfff)
161
+ return false;
162
+ }
163
+ return true;
164
+ }
145
165
  /**
146
166
  * Offers a read the route meta-tools.md prefers for finding something in it:
147
167
  * repeat the call inside a program and reduce it there. Paging comes second,
@@ -153,6 +173,8 @@ const WRITE_ALREADY_RAN = "This write already ran: do not call it again to see i
153
173
  const READ_REDUCE_FIRST = "To find something specific, repeat this read inside execute_code with connecta.call and filter or search the result there, returning only what matters";
154
174
  function pagingHint(results, totalBytes, preview) {
155
175
  if (!results.write) {
176
+ if (results.binding.program)
177
+ return "filter/map/slice data inside execute_code and return only what you need; page this completed return with connecta.result using nextAction.";
156
178
  const [shown, full] = preview.kind === "prefix"
157
179
  ? [`Bytes 0-${preview.bytes} of ${totalBytes} follow.`, "to read it in full, page the rest"]
158
180
  : preview.kind === "text-of-envelope"
@@ -177,8 +199,17 @@ function pagingHint(results, totalBytes, preview) {
177
199
  * documentation/meta-tools.md#result-representation). It is one compact JSON
178
200
  * line with no raw newline, so the preview starts after the first `\n`.
179
201
  */
180
- async function stashResult(bytes, results, preview) {
202
+ async function stashResult(bytes, results, preview, rawText) {
181
203
  const totalBytes = bytes.length;
204
+ // Direct pages promise offsets into the original UTF-8 text. JSON-escaping
205
+ // raw text would change that contract; never stash the encoder's replacement
206
+ // characters as if they were the completed call's output.
207
+ if (rawText !== undefined && !isWellFormedText(rawText))
208
+ return {
209
+ truncated: true,
210
+ totalBytes,
211
+ hint: `${results.write ? WRITE_ALREADY_RAN + " " : ""}Paging is unavailable: text contains unpaired surrogates and can't be paged as text; request the value as JSON (value mode or a JSON-returning call) to recover it.`,
212
+ };
182
213
  if (!results.pageable)
183
214
  return {
184
215
  truncated: true,
@@ -238,6 +269,74 @@ async function stashResult(bytes, results, preview) {
238
269
  },
239
270
  };
240
271
  }
272
+ /** Program calls use the direct-call envelope, ledger, expiry and authority. */
273
+ export async function pageProgramResult(registry, baseUrl, resolved, value, format, secrets, trust, requestScope, wireBytes = PROGRAM_RESULT_WIRE_BYTES) {
274
+ const data = secrets.redact(value);
275
+ const wire = JSON.stringify({ data, format });
276
+ if (enc.encode(wire).length <= wireBytes)
277
+ return { data, format };
278
+ const text = secrets.text(format === "text" ? String(data) : serializeResultText(data));
279
+ const bytes = enc.encode(text);
280
+ const write = resolved.definition.classification !== "read";
281
+ const notice = await stashResult(bytes, {
282
+ pageable: !write || trust === "trusted",
283
+ binding: {
284
+ identity: registry.resultIdentity(),
285
+ baseUrl,
286
+ connector: resolved.connector.id,
287
+ tool: resolved.definition.name,
288
+ classification: write ? "write" : "read",
289
+ },
290
+ write,
291
+ cap: resolveMaxResultBytes(resolved.connector.maxResultBytes, registry.maxResultBytes),
292
+ set: (id, chunks, ttl) => registry.stashResult(id, chunks, ttl),
293
+ warn: () => logFailure(registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger, "result paging unavailable", failureRecord({ connector: resolved.connector.id, tool: resolved.definition })),
294
+ }, { kind: "none" }, format === "text" ? text : undefined);
295
+ return {
296
+ format: "paged",
297
+ valueFormat: format,
298
+ ...(notice.resultId ? { resultId: notice.resultId } : {}),
299
+ totalBytes: bytes.length,
300
+ truncated: true,
301
+ hint: notice.resultId
302
+ ? `${write ? WRITE_ALREADY_RAN + " " : ""}Page this result with connecta.result(handle); do not repeat the call for output.`
303
+ : notice.hint,
304
+ };
305
+ }
306
+ /** Program returns share the direct stash, with all host-admitted call dependencies. */
307
+ export async function guardProgramReturn(value, registry, baseUrl, dependencies, secrets, trust, logger, maxChars, completedWrite) {
308
+ const serialized = serializeResultText(value);
309
+ if (serialized.length <= maxChars)
310
+ return value;
311
+ const text = secrets.text(serialized);
312
+ const bytes = enc.encode(text);
313
+ const write = dependencies.some((dependency) => dependency.classification === "write");
314
+ const cap = Math.min(registry.maxResultBytes, ...dependencies.map((dependency) => resolveMaxResultBytes(registry.getConnector(dependency.connector)?.maxResultBytes, registry.maxResultBytes)));
315
+ const results = {
316
+ secrets,
317
+ pageable: !write || trust === "trusted",
318
+ binding: {
319
+ identity: registry.resultIdentity(),
320
+ baseUrl,
321
+ classification: write ? "write" : "read",
322
+ program: dependencies,
323
+ },
324
+ write: completedWrite,
325
+ cap,
326
+ set: (id, chunks, ttl) => registry.stashResult(id, chunks, ttl),
327
+ warn: () => logFailure(logger, "result paging unavailable", failureRecord({})),
328
+ };
329
+ const notice = await stashResult(bytes, results, { kind: "none" }, text);
330
+ // Put the entire recovery route ahead of the preview in both response forms.
331
+ // A client retaining only the first 2,000 characters still sees the action.
332
+ if (notice.resultId) {
333
+ const base = { ...notice, totalChars: text.length, preview: "" };
334
+ const budget = Math.max(0, maxChars - JSON.stringify(base).length);
335
+ return { ...base, preview: escapedHeadOf(bytes, Math.min(512, budget)) };
336
+ }
337
+ const fallback = guardExecuteResultValue(secrets.redact(value), maxChars, notice.hint);
338
+ return { truncated: true, totalChars: fallback.totalChars, hint: fallback.hint, preview: fallback.preview };
339
+ }
241
340
  /** The first `cap` bytes of `bytes`, ending on a character boundary. */
242
341
  function headOf(bytes, cap) {
243
342
  return bytes.subarray(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length));
@@ -246,7 +345,6 @@ function headOf(bytes, cap) {
246
345
  function noticeFirst(notice, preview) {
247
346
  return {
248
347
  content: [{ type: "text", text: `${JSON.stringify(notice)}\n${preview}` }],
249
- structuredContent: { ...notice, format: "text" },
250
348
  };
251
349
  }
252
350
  /**
@@ -264,6 +362,10 @@ async function guardEncoded(text, bytes, results, cap) {
264
362
  truncated: false,
265
363
  };
266
364
  }
365
+ if (!isWellFormedText(text)) {
366
+ const notice = await stashResult(bytes, results, { kind: "none" }, text);
367
+ return { result: noticeFirst(notice, ""), truncated: true };
368
+ }
267
369
  const head = headOf(bytes, cap);
268
370
  const notice = await stashResult(bytes, results, {
269
371
  kind: "prefix",
@@ -380,13 +482,6 @@ async function guardContent(content, results, cap) {
380
482
  truncated: true,
381
483
  };
382
484
  }
383
- /**
384
- * The sentence that closes the OAuth handoff, telling the operator's agent how
385
- * to confirm the flow landed through the one surface it can call.
386
- */
387
- function oauthFollowUp(connectorId) {
388
- return `Then retry the original call; connecta.search({ connector: ${JSON.stringify(connectorId)} }) inside execute_code confirms the catalog now loads.`;
389
- }
390
485
  /**
391
486
  * Every meta-tool handler over a registry, one per registered tool. Exported for
392
487
  * direct testing; registerMetaTools() wires the six explicit tools onto an
@@ -415,6 +510,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
415
510
  // identity lets remote connectors reuse one downstream client inside that
416
511
  // request without leaking request-bound I/O into the next one.
417
512
  const requestScope = opts.requestScope ?? {};
513
+ if (opts.canManageAuth || opts.oauthConnectUrl || opts.credentialHandoffUrl || opts.oauthConnectUnavailable) {
514
+ bindAuthorizationHandoffs(requestScope, (connector) => authorizationHandoff(registry, baseUrl, requestScope, opts, { connector }));
515
+ }
418
516
  const sentSecrets = sentSecretsForRequest(requestScope);
419
517
  const skills = new SkillsRegistry(registry, baseUrl, {
420
518
  requestScope,
@@ -498,12 +596,14 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
498
596
  ];
499
597
  }
500
598
  const guarded = await guardContent(content, results, cap);
501
- guarded.result.structuredContent = { ...guarded.result.structuredContent, format };
599
+ // Native blocks and bounded previews are the result. A format-only
600
+ // structuredContent hides them in clients that prefer that field.
601
+ guarded.result._meta = { "dev.connecta/format": format };
502
602
  return processed(guarded.result, guarded.truncated);
503
603
  }
504
604
  const value = result;
505
605
  const guarded = await guardText(serializeResultText(value), results, cap);
506
- guarded.result.structuredContent = { ...guarded.result.structuredContent, format };
606
+ guarded.result._meta = { "dev.connecta/format": format };
507
607
  return processed(guarded.result, guarded.truncated, { value });
508
608
  },
509
609
  activityFriction: (processed) => processed.friction,
@@ -615,6 +715,9 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
615
715
  return errorResult(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
616
716
  `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
617
717
  }
718
+ if (args.page !== undefined && (!Number.isSafeInteger(args.page) || args.page < 0 || args.offset !== undefined)) {
719
+ return errorResult("Use a whole page >= 0, without offset.");
720
+ }
618
721
  const results = registry.resultsStorage();
619
722
  const unavailableResult = () => ({
620
723
  ...jsonResult({
@@ -668,21 +771,48 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
668
771
  binding.identity.principal !== identity.principal ||
669
772
  binding.identity.endpoint !== identity.endpoint ||
670
773
  binding.identity.origin !== identity.origin ||
671
- typeof binding.connector !== "string" ||
672
- typeof binding.tool !== "string" ||
774
+ (binding.program === undefined
775
+ ? typeof binding.connector !== "string" || typeof binding.tool !== "string"
776
+ : !Array.isArray(binding.program) ||
777
+ binding.program.some((dependency) => !dependency ||
778
+ typeof dependency.connector !== "string" ||
779
+ (dependency.tool !== undefined && typeof dependency.tool !== "string") ||
780
+ (dependency.access !== undefined &&
781
+ (dependency.access !== "catalog" || dependency.classification !== "read")) ||
782
+ !["read", "write"].includes(dependency.classification))) ||
673
783
  !["read", "write"].includes(binding.classification))
674
784
  return denied();
675
785
  const allowed = async () => {
676
786
  options.signal?.throwIfAborted();
677
787
  try {
678
- const address = `${binding.connector}.${binding.tool}`;
679
- if (!(await registry.recheckResultAccess(address, binding.classification, options.signal)))
788
+ if (binding.program &&
789
+ !(await registry.recheckResultAccess(undefined, binding.classification, options.signal)))
680
790
  return false;
681
- if (!registry.getConnector(binding.connector))
791
+ if (binding.classification === "write" && opts.trust !== "trusted")
682
792
  return false;
683
- const tools = await registry.getTools(binding.connector, baseUrl, requestScope, options.signal ? { signal: options.signal } : {});
684
- const tool = tools.find((tool) => tool.name === binding.tool);
685
- return Boolean(tool && ((binding.classification === "read" && tool.classification === "read") || opts.trust === "trusted"));
793
+ const dependencies = binding.program ?? [binding];
794
+ for (const dependency of dependencies) {
795
+ const address = dependency.tool === undefined ? dependency.connector : `${dependency.connector}.${dependency.tool}`;
796
+ if (!(await registry.recheckResultAccess(address, dependency.classification, options.signal, dependency.access)))
797
+ return false;
798
+ if (dependency.tool === undefined) {
799
+ if (dependency.access === "catalog"
800
+ ? !registry.getConnector(dependency.connector)
801
+ : !registry.getResourceConnector(dependency.connector))
802
+ return false;
803
+ continue;
804
+ }
805
+ if (!registry.getConnector(dependency.connector))
806
+ return false;
807
+ const tools = await registry.getTools(dependency.connector, baseUrl, requestScope, options.signal ? { signal: options.signal } : {});
808
+ const tool = tools.find((tool) => tool.name === dependency.tool);
809
+ if (!tool ||
810
+ !(dependency.access === "catalog" ||
811
+ (dependency.classification === "read" && tool.classification === "read") ||
812
+ opts.trust === "trusted"))
813
+ return false;
814
+ }
815
+ return true;
686
816
  }
687
817
  catch {
688
818
  options.signal?.throwIfAborted();
@@ -691,7 +821,8 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
691
821
  };
692
822
  if (!(await allowed()))
693
823
  return denied();
694
- const requestedOffset = args.offset ?? 0;
824
+ opts.onResultRead?.(binding.program ?? [binding]);
825
+ let requestedOffset = args.offset ?? 0;
695
826
  const cap = Number(header[4]);
696
827
  if (!isValidMaxResultBytes(cap))
697
828
  return denied();
@@ -708,6 +839,12 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
708
839
  chunkBytes < 3 ||
709
840
  chunkBytes % 3 !== 0)
710
841
  return denied();
842
+ if (args.page !== undefined) {
843
+ // At least one complete UTF-8 codepoint per nominal window. Adjacent
844
+ // pages align both edges backwards, so no character repeats or vanishes.
845
+ const width = Math.max(4, maxBytes);
846
+ requestedOffset = Math.min(total, Math.min(args.page, Math.ceil(total / width)) * width);
847
+ }
711
848
  start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
712
849
  const end = Math.min(total, requestedOffset + maxBytes + 4);
713
850
  bytes = new Uint8Array(Math.max(0, end - start));
@@ -744,7 +881,10 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
744
881
  // never split across pages (which would emit U+FFFD on both sides).
745
882
  // `nextOffset` is this aligned end, so it is a valid boundary for the
746
883
  // next call and paging reassembles the original byte-for-byte.
747
- const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
884
+ const end = args.page === undefined
885
+ ? start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start)
886
+ : start +
887
+ alignStartToCharBoundary(bytes, Math.min(total - start, requestedOffset - start + Math.max(4, maxBytes)));
748
888
  const slice = dec.decode(bytes.subarray(offset - start, end - start));
749
889
  const hasMore = end < total;
750
890
  // The same notice-first shape as a truncated call: one line of header,
@@ -766,76 +906,8 @@ function metaToolsForRequest(registry, baseUrl, opts = {}) {
766
906
  return page;
767
907
  },
768
908
  async authorizeConnector(args) {
769
- const connector = registry.getConnector(args.connector);
770
- if (!connector) {
771
- return errorResult(`Unknown connector "${boundedEchoText(args.connector)}"`);
772
- }
773
- if (!connector.startAuth) {
774
- if (!connector.credential) {
775
- return jsonResult({
776
- connector: connector.id,
777
- recovery: "unavailable",
778
- message: `Connector "${connector.id}" declares neither downstream OAuth ` +
779
- "nor an operator-managed credential slot. Update the connector " +
780
- "or deployment configuration before retrying.",
781
- });
782
- }
783
- const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
784
- if (!ctx.credential || !opts.credentialHandoffUrl) {
785
- return jsonResult({
786
- connector: connector.id,
787
- recovery: "unavailable",
788
- message: "Credential recovery needs both a vault and the optional UI. Configure " +
789
- "vault and ui in deployment code, then call " +
790
- "authorize_connector again.",
791
- });
792
- }
793
- const fields = connector.credential.fields?.map((field) => ({
794
- name: field.name,
795
- guidance: field.description ?? field.label,
796
- })) ?? [
797
- {
798
- name: "value",
799
- guidance: connector.credential.description ?? connector.credential.label,
800
- },
801
- ];
802
- return jsonResult({
803
- connector: connector.id,
804
- recovery: "operator_config",
805
- credential: {
806
- label: connector.credential.label,
807
- fields,
808
- },
809
- operatorUrl: opts.credentialHandoffUrl,
810
- instructions: "Have the operator open operatorUrl, set and test the credential, " +
811
- "then retry the original call. No redeploy is needed. " +
812
- (connector.authScope === "personal"
813
- ? "Credential mutation requires the signed-in principal who owns this connection."
814
- : "Shared credential mutation requires a signed-in human with access to this connector."),
815
- });
816
- }
817
- if (opts.oauthConnectUnavailable || !opts.oauthConnectUrl)
818
- return jsonResult({
819
- connector: connector.id,
820
- recovery: "unavailable",
821
- message: opts.oauthConnectUnavailable ??
822
- "OAuth connection requires an interactive provider and a credential vault with a handoff signing key.",
823
- });
824
- if (!opts.canManageAuth?.(connector.id))
825
- return jsonResult({
826
- connector: connector.id,
827
- recovery: "unavailable",
828
- message: "Your identity is not permitted to manage authentication for this connection.",
829
- });
830
909
  try {
831
- return jsonResult({
832
- connector: connector.id,
833
- recovery: "oauth",
834
- status: "auth_required",
835
- authorizationUrl: await opts.oauthConnectUrl(connector.id, args.force),
836
- instructions: "Open authorizationUrl in a browser and sign in as the user who requested this connection. Connecta verifies your identity and permission before starting consent. " +
837
- oauthFollowUp(connector.id),
838
- });
910
+ return jsonResult(await authorizationHandoff(registry, baseUrl, requestScope, opts, args));
839
911
  }
840
912
  catch (err) {
841
913
  return errorResult(msg(err));
@@ -967,7 +1039,7 @@ export function registerMetaTools(server, registry, ctx) {
967
1039
  server.registerTool("call_tool", {
968
1040
  description: CALL_DESC,
969
1041
  inputSchema: CALL_INPUT,
970
- outputSchema: CALL_OUTPUT,
1042
+ // Default mode returns native content, so no structured result is promised.
971
1043
  // call_tool admits only tools that are themselves explicitly read-only;
972
1044
  // anything else is refused and routed to call_destructive_tool.
973
1045
  annotations: READ_ONLY_REMOTE,
@@ -980,7 +1052,6 @@ export function registerMetaTools(server, registry, ctx) {
980
1052
  server.registerTool("call_destructive_tool", {
981
1053
  description: describedFor(registry, CALL_DESTRUCTIVE_DESC, "destructive"),
982
1054
  inputSchema: CALL_DESTRUCTIVE_INPUT,
983
- outputSchema: CALL_OUTPUT,
984
1055
  annotations: {
985
1056
  destructiveHint: true,
986
1057
  readOnlyHint: false,
@@ -1,6 +1,66 @@
1
1
  import { oauthConnectKeys } from "./storage/keys.js";
2
2
  const HANDOFF_TTL_MS = 15 * 60_000;
3
3
  const INTERACTIVE_OAUTH_REQUIRED = "An interactive provider (Clerk or Cloudflare Access) is required to connect OAuth connectors.";
4
+ // Length-prefixed fields avoid JSON keys and escaping without reserving any
5
+ // character in an identity. This is an opaque wire encoding, not encryption.
6
+ function encodeHandoff(h) {
7
+ return [
8
+ h.connector,
9
+ h.owner,
10
+ h.principal,
11
+ h.origin,
12
+ h.expiresAt.toString(36),
13
+ h.nonce,
14
+ h.force ? "1" : "0",
15
+ h.after ?? "",
16
+ ]
17
+ .map((field) => `${field.length}:${field}`)
18
+ .join("");
19
+ }
20
+ function decodeHandoff(value) {
21
+ const fields = [];
22
+ let offset = 0;
23
+ while (offset < value.length && fields.length < 8) {
24
+ const colon = value.indexOf(":", offset);
25
+ const lengthText = value.slice(offset, colon);
26
+ if (colon < offset || !/^(0|[1-9][0-9]*)$/.test(lengthText))
27
+ throw new Error("Invalid handoff field");
28
+ const length = Number(lengthText);
29
+ offset = colon + 1;
30
+ if (!Number.isSafeInteger(length) || length > value.length - offset)
31
+ throw new Error("Invalid handoff length");
32
+ fields.push(value.slice(offset, offset + length));
33
+ offset += length;
34
+ }
35
+ const [connector, owner, principal, origin, expiry, nonce, force, after] = fields;
36
+ if (fields.length !== 8 || offset !== value.length || !/^[0-9a-z]+$/.test(expiry) || !/^[01]$/.test(force))
37
+ throw new Error("Invalid handoff fields");
38
+ return {
39
+ connector: connector,
40
+ owner: owner,
41
+ principal: principal,
42
+ origin: origin,
43
+ expiresAt: parseInt(expiry, 36),
44
+ nonce: nonce,
45
+ force: force === "1",
46
+ ...(after ? { after } : {}),
47
+ };
48
+ }
49
+ function encodeSegment(value) {
50
+ let binary = "";
51
+ for (const byte of new TextEncoder().encode(value))
52
+ binary += String.fromCharCode(byte);
53
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
54
+ }
55
+ function decodeSegment(value) {
56
+ if (!/^[A-Za-z0-9_-]+$/.test(value))
57
+ throw new Error("Invalid handoff encoding");
58
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
59
+ const decoded = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
60
+ if (encodeSegment(decoded) !== value)
61
+ throw new Error("Noncanonical handoff encoding");
62
+ return decoded;
63
+ }
4
64
  export function oauthConnectUnavailable(opts) {
5
65
  if (!opts.config.auth.some((provider) => provider.interactiveOperator &&
6
66
  (provider.uiAuth?.kind === "clerk" || provider.uiAuth?.kind === "cloudflare-access"))) {
@@ -34,10 +94,13 @@ export async function oauthConnectLink(opts, baseUrl, connectorId, principal, fo
34
94
  const vault = opts.config.vault;
35
95
  if (opaque && (!vault.seal || !vault.open))
36
96
  throw new Error("URL elicitation requires a sealing credential vault.");
97
+ const compact = encodeHandoff(handoff);
37
98
  const payload = opaque
38
- ? `v2:${btoa(await vault.seal(connectorId, "connecta:connect-link:v2", JSON.stringify(handoff)))}`
39
- : btoa(JSON.stringify(handoff));
40
- const signature = await opts.config.vault.signOAuthHandoff(payload);
99
+ ? `v4.${encodeSegment(await vault.seal(connectorId, "connecta:connect-link:v4", compact))}`
100
+ : `v3.${encodeSegment(compact)}`;
101
+ // Preserve the vault's entire signature and its representation, including
102
+ // custom vaults, while keeping every URL segment unpadded base64url.
103
+ const signature = encodeSegment(await vault.signOAuthHandoff(payload));
41
104
  const url = new URL(`/connect/${connectorId}`, baseUrl);
42
105
  url.searchParams.set("h", `${payload}.${signature}`);
43
106
  return { url: url.toString(), nonce: handoff.nonce };
@@ -63,15 +126,29 @@ export async function claimAuthRetry(opts, baseUrl, id, nonce) {
63
126
  export async function verifyOAuthHandoff(opts, baseUrl, connectorId, token) {
64
127
  if (!token || token.length > 4096)
65
128
  return null;
66
- const [payload, signature, ...rest] = token.split(".");
67
- if (!payload || !signature || rest.length)
68
- return null;
69
129
  try {
70
- if (!(await opts.config.vault?.verifyOAuthHandoff?.(payload, signature)))
71
- return null;
72
- const h = JSON.parse(payload.startsWith("v2:")
73
- ? await opts.config.vault.open(connectorId, "connecta:connect-link:v2", atob(payload.slice(3)))
74
- : atob(payload));
130
+ const parts = token.split(".");
131
+ let h;
132
+ if (parts.length === 3 && (parts[0] === "v3" || parts[0] === "v4")) {
133
+ const [version, body, signature] = parts;
134
+ const payload = `${version}.${body}`;
135
+ if (!(await opts.config.vault?.verifyOAuthHandoff?.(payload, decodeSegment(signature))))
136
+ return null;
137
+ const decoded = decodeSegment(body);
138
+ h = decodeHandoff(version === "v4" ? await opts.config.vault.open(connectorId, "connecta:connect-link:v4", decoded) : decoded);
139
+ }
140
+ else {
141
+ // Keep pre-upgrade JSON and sealed v2 links until their original expiry.
142
+ // Authenticate their exact original text; never normalize before verifying.
143
+ const [payload, signature, ...rest] = parts;
144
+ if (!payload || !signature || rest.length)
145
+ return null;
146
+ if (!(await opts.config.vault?.verifyOAuthHandoff?.(payload, signature)))
147
+ return null;
148
+ h = JSON.parse(payload.startsWith("v2:")
149
+ ? await opts.config.vault.open(connectorId, "connecta:connect-link:v2", atob(payload.slice(3)))
150
+ : atob(payload));
151
+ }
75
152
  const connector = opts.registry.getConnector(connectorId);
76
153
  if (!connector ||
77
154
  h.connector !== connectorId ||