@zackbart/connecta 0.5.0 → 0.6.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 (76) hide show
  1. package/CHANGELOG.md +505 -0
  2. package/README.md +159 -267
  3. package/dist/auth/bearer.d.ts +10 -3
  4. package/dist/auth/bearer.d.ts.map +1 -1
  5. package/dist/auth/bearer.js +21 -0
  6. package/dist/auth/bearer.js.map +1 -1
  7. package/dist/auth/clerk.d.ts +28 -3
  8. package/dist/auth/clerk.d.ts.map +1 -1
  9. package/dist/auth/clerk.js +161 -4
  10. package/dist/auth/clerk.js.map +1 -1
  11. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  12. package/dist/connectors/remote-mcp.js +8 -0
  13. package/dist/connectors/remote-mcp.js.map +1 -1
  14. package/dist/credential-health.d.ts +220 -0
  15. package/dist/credential-health.d.ts.map +1 -0
  16. package/dist/credential-health.js +551 -0
  17. package/dist/credential-health.js.map +1 -0
  18. package/dist/credentials.d.ts +35 -1
  19. package/dist/credentials.d.ts.map +1 -1
  20. package/dist/credentials.js +42 -0
  21. package/dist/credentials.js.map +1 -1
  22. package/dist/execute.d.ts.map +1 -1
  23. package/dist/execute.js +16 -4
  24. package/dist/execute.js.map +1 -1
  25. package/dist/index.d.ts +46 -5
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +118 -15
  28. package/dist/index.js.map +1 -1
  29. package/dist/meta-tools.d.ts +56 -5
  30. package/dist/meta-tools.d.ts.map +1 -1
  31. package/dist/meta-tools.js +249 -92
  32. package/dist/meta-tools.js.map +1 -1
  33. package/dist/registry.d.ts +62 -0
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +85 -1
  36. package/dist/registry.js.map +1 -1
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +305 -40
  39. package/dist/server.js.map +1 -1
  40. package/dist/skills.d.ts +1 -1
  41. package/dist/skills.d.ts.map +1 -1
  42. package/dist/skills.js +3 -3
  43. package/dist/skills.js.map +1 -1
  44. package/dist/timeout.d.ts +16 -0
  45. package/dist/timeout.d.ts.map +1 -0
  46. package/dist/timeout.js +38 -0
  47. package/dist/timeout.js.map +1 -0
  48. package/dist/toolkits.d.ts +95 -1
  49. package/dist/toolkits.d.ts.map +1 -1
  50. package/dist/toolkits.js +190 -5
  51. package/dist/toolkits.js.map +1 -1
  52. package/dist/types.d.ts +81 -0
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/ui.d.ts +52 -0
  55. package/dist/ui.d.ts.map +1 -1
  56. package/dist/ui.js +144 -13
  57. package/dist/ui.js.map +1 -1
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/package.json +1 -1
  61. package/src/auth/bearer.ts +35 -1
  62. package/src/auth/clerk.ts +204 -7
  63. package/src/connectors/remote-mcp.ts +9 -0
  64. package/src/credential-health.ts +753 -0
  65. package/src/credentials.ts +71 -1
  66. package/src/execute.ts +28 -4
  67. package/src/index.ts +204 -22
  68. package/src/meta-tools.ts +286 -109
  69. package/src/registry.ts +125 -1
  70. package/src/server.ts +366 -38
  71. package/src/skills.ts +3 -3
  72. package/src/timeout.ts +49 -0
  73. package/src/toolkits.ts +241 -6
  74. package/src/types.ts +87 -1
  75. package/src/ui.ts +156 -14
  76. package/src/version.ts +1 -1
package/src/meta-tools.ts CHANGED
@@ -26,6 +26,12 @@ import {
26
26
  listSkills,
27
27
  resolveSkill,
28
28
  } from "./skills.js";
29
+ import {
30
+ DEFAULT_PROBE_TIMEOUT_MS,
31
+ normalizeTimeoutMs,
32
+ withTimeout,
33
+ } from "./timeout.js";
34
+ import { credentialVerdictApplies } from "./credential-health.js";
29
35
  import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
30
36
 
31
37
  interface TextContent {
@@ -75,50 +81,6 @@ type ErrorDetails = CallErrorDetails;
75
81
  */
76
82
  export const MAX_RETRY_BACKOFF_MS = 10_000;
77
83
 
78
- /** A finite, positive integer number of milliseconds, or undefined. */
79
- function normalizeTimeoutMs(value: number | undefined): number | undefined {
80
- if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
81
- return undefined;
82
- }
83
- return Math.max(1, Math.trunc(value));
84
- }
85
-
86
- /**
87
- * Generous default bound for a single downstream probe/catalog call in the
88
- * list/search/describe fan-out. High enough to trip only on a pathological
89
- * hang, not a realistically slow probe.
90
- */
91
- const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
92
-
93
- /**
94
- * Reject `promise` after `ms` if it has not settled, so one hung downstream
95
- * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
96
- * the registry probe methods take no AbortSignal, so the underlying fetch is
97
- * NOT cancelled and keeps running in the background. Real cancellation
98
- * (AbortSignal plumbed through the registry) is a deferred follow-up.
99
- */
100
- function withTimeout<T>(
101
- promise: Promise<T>,
102
- ms: number,
103
- label: string,
104
- ): Promise<T> {
105
- return new Promise<T>((resolve, reject) => {
106
- const timer = setTimeout(() => {
107
- reject(new Error(`${label} timed out after ${ms}ms`));
108
- }, ms);
109
- promise.then(
110
- (value) => {
111
- clearTimeout(timer);
112
- resolve(value);
113
- },
114
- (err) => {
115
- clearTimeout(timer);
116
- reject(err);
117
- },
118
- );
119
- });
120
- }
121
-
122
84
  /**
123
85
  * How long to wait before the next attempt, or `undefined` for "don't retry".
124
86
  *
@@ -153,6 +115,48 @@ function isContinuationByte(b: number): boolean {
153
115
  return (b & 0xc0) === 0x80;
154
116
  }
155
117
 
118
+ /** Smallest accepted `get_result` byte offset. */
119
+ export const MIN_RESULT_OFFSET = 0;
120
+
121
+ /**
122
+ * The one definition of a usable `get_result` offset: a whole number of bytes
123
+ * at or past {@link MIN_RESULT_OFFSET}. Shared by the registered zod schema and
124
+ * the handler's own check, the way `isValidMaxResultBytes` is shared across the
125
+ * cap's intake points (issue #32) — so a value valid at the wire is valid in
126
+ * process, and the two cannot drift.
127
+ *
128
+ * Everything else is rejected rather than coerced, because coercion is how an
129
+ * out-of-domain offset used to void a result silently: `Math.max(0, NaN)` is
130
+ * `NaN`, which slices to nothing, serializes as `"offset": null`, and reports
131
+ * no `nextOffset` — a caller sees a successful, empty result instead of an
132
+ * error. An offset past the end of the payload stays legal: it is a whole
133
+ * number of bytes, and it answers with an empty final page.
134
+ */
135
+ export function isValidResultOffset(value: number): boolean {
136
+ return Number.isInteger(value) && value >= MIN_RESULT_OFFSET;
137
+ }
138
+
139
+ /**
140
+ * Move a byte `offset` back to the nearest UTF-8 codepoint boundary in
141
+ * `[0, offset]`, so decoding from it never starts mid-character (which emits
142
+ * U+FFFD for the severed tail).
143
+ *
144
+ * Backwards, never forwards: re-serving a few bytes the caller already has is
145
+ * recoverable, silently skipping the rest of a character is not. Offsets the
146
+ * server itself produced (`nextOffset`) are already boundaries and come back
147
+ * unchanged, so this only moves an offset a client computed on its own
148
+ * (issue #38). An offset at or past `bytes.length` is left alone — there is no
149
+ * character there to split.
150
+ */
151
+ export function alignStartToCharBoundary(
152
+ bytes: Uint8Array,
153
+ offset: number,
154
+ ): number {
155
+ let o = offset;
156
+ while (o > 0 && isContinuationByte(bytes[o])) o--;
157
+ return o;
158
+ }
159
+
156
160
  /**
157
161
  * Move a byte `end` back to the nearest UTF-8 codepoint boundary in
158
162
  * `(offset, total]`, so decoding `bytes[offset, end)` never splits a codepoint
@@ -240,39 +244,88 @@ function applyFieldsToContent(
240
244
 
241
245
  // --- result-size guard + get_result (feature 1) ---------------------------
242
246
 
243
- function contentBytes(content: TextContent[]): number {
244
- let n = 0;
245
- for (const b of content)
246
- if (b.type === "text") n += enc.encode(b.text).length;
247
- return n;
247
+ /**
248
+ * The one serialization every result guard measures, stashes, and pages: JSON
249
+ * text for whatever JSON can represent, and `String(value)` for the returns
250
+ * JSON renders as `undefined` — a handler that returns nothing, a function, or
251
+ * a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
252
+ * returning `undefined` for those, which is how a handler returning `undefined`
253
+ * reached clients as a `{"type":"text"}` block carrying no `text` at all: the
254
+ * size guard measured `enc.encode(undefined)` — the empty string, per the
255
+ * WebIDL default — and emitted the non-string unchanged (issue #42). `null`
256
+ * needs no special case; JSON renders it as `"null"`.
257
+ *
258
+ * Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
259
+ * the three give one answer to the same question. A value JSON cannot serialize
260
+ * at all (a BigInt) still throws, as before, and is reported as a failure.
261
+ */
262
+ export function serializeResultText(value: unknown): string {
263
+ const serialized = JSON.stringify(value, null, 2);
264
+ return serialized === undefined ? String(value) : serialized;
265
+ }
266
+
267
+ /**
268
+ * Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
269
+ * truncation notice every over-cap path hands back.
270
+ */
271
+ async function stashResult(
272
+ text: string,
273
+ results: KVStorage,
274
+ totalBytes: number,
275
+ ): Promise<{
276
+ truncated: true;
277
+ resultId: string;
278
+ totalBytes: number;
279
+ hint: string;
280
+ }> {
281
+ const id = crypto.randomUUID();
282
+ await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
283
+ return {
284
+ truncated: true,
285
+ resultId: id,
286
+ totalBytes,
287
+ hint: "use get_result {id, offset} to page, or re-call with fields to select less",
288
+ };
248
289
  }
249
290
 
250
291
  /**
251
292
  * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
252
- * full text under `result:<uuid>` (ttl 900s) and return the first `cap` bytes
253
- * followed by a JSON truncation notice pointing at get_result.
293
+ * full text and return the first `cap` bytes followed by a JSON truncation
294
+ * notice pointing at get_result. `bytes` is `text` already encoded, so a caller
295
+ * that had to measure it to make this decision doesn't encode it twice.
254
296
  */
255
- async function guardText(
297
+ async function guardEncoded(
256
298
  text: string,
299
+ bytes: Uint8Array,
257
300
  results: KVStorage,
258
301
  cap: number,
259
302
  ): Promise<ToolResult> {
260
- const bytes = enc.encode(text);
261
303
  if (bytes.length <= cap) {
262
304
  return { content: [{ type: "text", text }] };
263
305
  }
264
- const id = crypto.randomUUID();
265
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
306
+ const notice = await stashResult(text, results, bytes.length);
266
307
  const head = dec.decode(
267
308
  bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)),
268
309
  );
269
- const notice = JSON.stringify({
270
- truncated: true,
271
- resultId: id,
272
- totalBytes: bytes.length,
273
- hint: "use get_result {id, offset} to page, or re-call with fields to select less",
274
- });
275
- return { content: [{ type: "text", text: `${head}\n${notice}` }] };
310
+ return {
311
+ content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
312
+ };
313
+ }
314
+
315
+ /** {@link guardEncoded} over a string that has not been measured yet. */
316
+ async function guardText(
317
+ text: string,
318
+ results: KVStorage,
319
+ cap: number,
320
+ ): Promise<ToolResult> {
321
+ // `JSON.stringify`'s type says `string` where its behavior says `string |
322
+ // undefined`, so TypeScript alone does not keep a non-string out of here.
323
+ // Normalizing at the door means the size check below always measures exactly
324
+ // the text that is emitted, and no future caller can launder a non-string
325
+ // through it the way issue #42 describes.
326
+ const body: string =
327
+ typeof text === "string" ? text : serializeResultText(text);
328
+ return guardEncoded(body, enc.encode(body), results, cap);
276
329
  }
277
330
 
278
331
  /** Store an oversized JSON value and replace it with a page handle. */
@@ -281,17 +334,54 @@ async function guardValue(
281
334
  results: KVStorage,
282
335
  cap: number,
283
336
  ): Promise<unknown> {
284
- const text = JSON.stringify(value, null, 2) ?? String(value);
337
+ const text = serializeResultText(value);
285
338
  const bytes = enc.encode(text);
286
339
  if (bytes.length <= cap) return value;
287
- const id = crypto.randomUUID();
288
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
289
- return {
290
- truncated: true,
291
- resultId: id,
292
- totalBytes: bytes.length,
293
- hint: "use get_result {id, offset} to page, or re-call with fields to select less",
294
- };
340
+ return stashResult(text, results, bytes.length);
341
+ }
342
+
343
+ /**
344
+ * Bound a downstream MCP `content` array by `cap`, measuring the serialized
345
+ * envelope — the same string that gets stashed and paged, and the one that
346
+ * counts every block rather than only the text ones.
347
+ *
348
+ * Both halves matter (issue #43). Measuring only text blocks meant an oversized
349
+ * all-image result scored zero bytes and was returned inline unbounded, with no
350
+ * `resultId` to page from; and measuring one string while truncating another
351
+ * left `totalBytes` and the served head describing something the cap was never
352
+ * compared against.
353
+ *
354
+ * Over the cap, what a client gets depends on whether a prefix is usable. An
355
+ * all-text envelope keeps the historical head + notice — a JSON prefix is still
356
+ * readable. An envelope carrying non-text blocks is replaced by the notice
357
+ * alone: the head of a half-written base64 image is of no use to anyone, and
358
+ * cutting one leaves unparseable block structure behind. Either way the full
359
+ * envelope is stashed and pages through `get_result`.
360
+ */
361
+ async function guardContent(
362
+ content: TextContent[],
363
+ results: KVStorage,
364
+ cap: number,
365
+ ): Promise<ToolResult> {
366
+ let text: string;
367
+ try {
368
+ text = JSON.stringify(content, null, 2);
369
+ } catch {
370
+ // A block carrying a BigInt or a cycle cannot be serialized, so it cannot
371
+ // be measured, stashed, or paged either — there is nothing this guard could
372
+ // do with it. Pass it through as the old text-only measure did, rather than
373
+ // turning a call that used to succeed into result_processing_failed.
374
+ return { content };
375
+ }
376
+ const bytes = enc.encode(text);
377
+ // Under the cap the downstream blocks pass through untouched, non-text ones
378
+ // included, in their original order.
379
+ if (bytes.length <= cap) return { content };
380
+ if (content.every((b) => b.type === "text")) {
381
+ return guardEncoded(text, bytes, results, cap);
382
+ }
383
+ const notice = await stashResult(text, results, bytes.length);
384
+ return { content: [{ type: "text", text: JSON.stringify(notice) }] };
295
385
  }
296
386
 
297
387
  // --- compact schema rendering (feature 3a) --------------------------------
@@ -329,6 +419,10 @@ export interface CallArgs {
329
419
  }
330
420
  export interface GetResultArgs {
331
421
  id: string;
422
+ /**
423
+ * Byte offset to page from; a whole number >= 0, aligned back to the nearest
424
+ * character boundary and reported as the response's `offset`. Defaults to 0.
425
+ */
332
426
  offset?: number;
333
427
  /** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
334
428
  maxBytes?: number;
@@ -359,17 +453,19 @@ export interface SkillArgs {
359
453
 
360
454
  /**
361
455
  * The nine meta-tool handlers over a registry. Exported for direct testing;
362
- * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
363
- * overrides the registry's default result-size cap (a connector's own
364
- * `maxResultBytes` overrides it in turn); `opts.defaultToolTimeoutMs`
456
+ * registerMetaTools() wires them onto an McpServer. `opts.defaultToolTimeoutMs`
365
457
  * supplies a deadline for calls that don't carry one. (execute_code, the
366
458
  * optional tenth tool, is registered separately by registerExecuteTool.)
459
+ *
460
+ * The deployment-wide result-size cap is read off the registry view rather than
461
+ * passed in: `ConnectaConfig.maxResultBytes` and the per-connector override are
462
+ * the only places a cap is set, so there is one answer to where a deployment
463
+ * sets it (issue #44).
367
464
  */
368
465
  export function createMetaTools(
369
466
  registry: RegistryView,
370
467
  baseUrl: string,
371
468
  opts: {
372
- maxResultBytes?: number;
373
469
  /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
374
470
  defaultToolTimeoutMs?: number;
375
471
  /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
@@ -377,10 +473,8 @@ export function createMetaTools(
377
473
  activity?: ActivityRequestContext;
378
474
  } = {},
379
475
  ) {
380
- const globalCap = resolveMaxResultBytes(
381
- opts.maxResultBytes,
382
- registry.maxResultBytes,
383
- );
476
+ // Already normalized and warned about at registry construction.
477
+ const globalCap = registry.maxResultBytes;
384
478
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
385
479
  const probeTimeoutMs =
386
480
  normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
@@ -493,6 +587,20 @@ export function createMetaTools(
493
587
  ).find((tool) => tool.name === resolved.toolName);
494
588
  } catch (err) {
495
589
  catalogMs += Date.now() - catalogStarted;
590
+ // A connector whose catalog cannot be fetched is as unusable as one whose
591
+ // execution fails, so it feeds health accounting the same way the
592
+ // execution catch below does — otherwise a connector every call_tool
593
+ // fails against (a revoked downstream grant, say) still reads clean from
594
+ // the cheap `list_connectors({ probe: false })` signal.
595
+ //
596
+ // Recorded HERE rather than inside the registry's catalog fetch on
597
+ // purpose: `registry` is this connection's VIEW, so a toolkit-scoped
598
+ // session records into its own log as well as the deployment-wide one,
599
+ // which `Registry.refreshTools` could not reach. A cache hit that avoids
600
+ // a live listTools call therefore records nothing either way — it is not
601
+ // evidence of health, and success stays what it has always been: an
602
+ // actual downstream call that returned.
603
+ registry.recordFailure(resolved.connector.id, Date.now() - started, err);
496
604
  // classifyCallError so a typed auth_required thrown while listing tools
497
605
  // (e.g. a revoked downstream OAuth grant) keeps its code.
498
606
  return failed(classifyCallError(err, "catalog_lookup_failed"));
@@ -625,16 +733,7 @@ export function createMetaTools(
625
733
  if (resolved.connector.kind === "mcp") {
626
734
  let content = mr?.content ?? [];
627
735
  if (fields) content = applyFieldsToContent(content, fields);
628
- let toolResult: ToolResult;
629
- if (contentBytes(content) > cap) {
630
- toolResult = await guardText(
631
- JSON.stringify(content, null, 2),
632
- results,
633
- cap,
634
- );
635
- } else {
636
- toolResult = { content };
637
- }
736
+ const toolResult = await guardContent(content, results, cap);
638
737
  resultProcessingMs += Date.now() - processingStarted;
639
738
  record("success");
640
739
  return {
@@ -646,7 +745,7 @@ export function createMetaTools(
646
745
  }
647
746
  const value = fields ? applyFields(result, fields) : result;
648
747
  const toolResult = await guardText(
649
- JSON.stringify(value, null, 2),
748
+ serializeResultText(value),
650
749
  results,
651
750
  cap,
652
751
  );
@@ -691,9 +790,9 @@ export function createMetaTools(
691
790
  const probe = args.probe ?? true;
692
791
  const out = await Promise.all(
693
792
  registry.listConnectors().map(async (c) => {
694
- const checkedAt = new Date().toISOString();
695
793
  const statusStarted = Date.now();
696
794
  const observed = registry.healthFor(c.id);
795
+ const verdict = await registry.credentialHealthFor(c.id);
697
796
  let status:
698
797
  | ConnectorStatus
699
798
  | { state: "ok" | "error" | "unknown"; message?: string };
@@ -710,23 +809,76 @@ export function createMetaTools(
710
809
  // hanging the whole list_connectors call.
711
810
  status = { state: "error", message: msg(err) };
712
811
  }
812
+ } else if (
813
+ verdict &&
814
+ // Deployment-wide, deliberately, like `hasObservedSuccess` beside
815
+ // it: a sibling toolkit's successful call proves the shared
816
+ // credential works, and a verdict retired for one view but not
817
+ // another would make the same connector read differently per scope
818
+ // for a reason that has nothing to do with scope.
819
+ credentialVerdictApplies(verdict, registry.observedSuccessAt(c.id))
820
+ ) {
821
+ // The proactive layer (issue #24): a liveness check already found
822
+ // the stored credential dead, so say so on the cheap path instead of
823
+ // waiting for an agent's real call to discover it. Only while it is
824
+ // the freshest evidence — a successful call since then retires it.
825
+ status = {
826
+ state: verdict.state,
827
+ ...(verdict.message ? { message: verdict.message } : {}),
828
+ ...(verdict.authorizationUrl
829
+ ? { authorizationUrl: verdict.authorizationUrl }
830
+ : {}),
831
+ };
713
832
  } else {
714
833
  // "error" comes from THIS view's own observations — a sibling
715
834
  // toolkit's failure is not this session's experience — while
716
835
  // ok/unknown may lean on the deployment-wide success signal, since
717
836
  // "the connector answers at all" is a fact about the connector.
718
837
  // Unscoped, the two are the same log, so this is unchanged there.
838
+ const derived =
839
+ observed?.consecutiveFailures && observed.consecutiveFailures > 0
840
+ ? ("error" as const)
841
+ : registry.hasObservedSuccess(c.id) || c.kind === "api"
842
+ ? ("ok" as const)
843
+ : ("unknown" as const);
719
844
  status = {
845
+ // A successful liveness check upgrades "unknown" — nothing has
846
+ // been called yet, but the credential was verified, which is how
847
+ // re-authorization shows up here as ok rather than as an absence
848
+ // of evidence. It never DOWNgrades an observed failure: a real
849
+ // call that failed is stronger evidence than a background check.
720
850
  state:
721
- observed?.consecutiveFailures &&
722
- observed.consecutiveFailures > 0
723
- ? ("error" as const)
724
- : registry.hasObservedSuccess(c.id) || c.kind === "api"
725
- ? ("ok" as const)
726
- : ("unknown" as const),
851
+ derived === "unknown" && verdict?.state === "ok"
852
+ ? ("ok" as const)
853
+ : derived,
727
854
  ...(observed?.lastError ? { message: observed.lastError } : {}),
728
855
  };
729
856
  }
857
+ // Stamped where the observation actually happened — after the status
858
+ // probe, not before it. A 30-second probe stamped at its start would
859
+ // report a verdict older than it is, and would lose the race against a
860
+ // real call that succeeded WHILE it ran (that success must retire the
861
+ // verdict, and only an honest timestamp says so).
862
+ const checkedAt = new Date().toISOString();
863
+ // A live status probe IS a liveness observation of the stored
864
+ // credential, so it updates the same verdict a background check
865
+ // writes: the cached read afterwards agrees with what the operator
866
+ // just saw, and they are not swept again moments later. Recorded from
867
+ // the STATUS phase only, and only when the connector actually answered
868
+ // — a catalog refresh below is not a credential check (the sweep never
869
+ // fetches one), it is already counted in the health log, and letting
870
+ // its failure land here would spend the freshness budget on it. The
871
+ // registry ignores this for connectors storing no credential of ours.
872
+ if (probe && (status.state === "ok" || status.state === "auth_required")) {
873
+ await registry.recordCredentialHealth(c.id, {
874
+ state: status.state,
875
+ checkedAt,
876
+ ...(status.message ? { message: status.message } : {}),
877
+ ...("authorizationUrl" in status && status.authorizationUrl
878
+ ? { authorizationUrl: status.authorizationUrl }
879
+ : {}),
880
+ });
881
+ }
730
882
  let tools = registry.peekTools(c.id);
731
883
  // An auth_required status may have just started OAuth. A second
732
884
  // listTools probe would overwrite its state/verifier while returning
@@ -746,6 +898,9 @@ export function createMetaTools(
746
898
  }
747
899
  const latencyMs = Date.now() - statusStarted;
748
900
  const latestObserved = registry.healthFor(c.id);
901
+ const credentialCheck = probe
902
+ ? await registry.credentialHealthFor(c.id)
903
+ : verdict;
749
904
  return {
750
905
  id: c.id,
751
906
  ...(c.title ? { title: c.title } : {}),
@@ -756,6 +911,7 @@ export function createMetaTools(
756
911
  latencyMs,
757
912
  probe,
758
913
  ...(latestObserved ?? observed ?? {}),
914
+ ...(credentialCheck ? { credentialCheck } : {}),
759
915
  ...("authorizationUrl" in status && status.authorizationUrl
760
916
  ? { authorizationUrl: status.authorizationUrl }
761
917
  : {}),
@@ -974,11 +1130,11 @@ export function createMetaTools(
974
1130
  },
975
1131
 
976
1132
  async getResult(args: GetResultArgs): Promise<ToolResult> {
977
- // Client-supplied page size: a normal input-validation error, not a
978
- // clamp. Callers arriving over MCP are rejected earlier by the
979
- // registered zod schema and never reach this branch, so it exists for
1133
+ // Client-supplied page size and offset: normal input-validation errors,
1134
+ // not clamps. Callers arriving over MCP are rejected earlier by the
1135
+ // registered zod schema and never reach these branches, so they exist for
980
1136
  // in-process callers of createMetaTools — which have no schema in front
981
- // of them — and to keep the rule true of the handler on its own terms.
1137
+ // of them — and to keep the rules true of the handler on its own terms.
982
1138
  if (
983
1139
  args.maxBytes !== undefined &&
984
1140
  !isValidMaxResultBytes(args.maxBytes)
@@ -988,6 +1144,12 @@ export function createMetaTools(
988
1144
  `>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`,
989
1145
  );
990
1146
  }
1147
+ if (args.offset !== undefined && !isValidResultOffset(args.offset)) {
1148
+ return errorResult(
1149
+ `Invalid offset ${args.offset}: must be a whole number of bytes ` +
1150
+ `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`,
1151
+ );
1152
+ }
991
1153
  const results = registry.resultsStorage();
992
1154
  const stored = await results.get(`result:${args.id}`);
993
1155
  if (stored === null || stored === undefined) {
@@ -995,7 +1157,12 @@ export function createMetaTools(
995
1157
  }
996
1158
  const bytes = enc.encode(stored);
997
1159
  const total = bytes.length;
998
- const offset = Math.max(0, Math.trunc(args.offset ?? 0));
1160
+ // Validated above, so no coercion is needed here — only alignment. A
1161
+ // client that computes its own offsets can land inside a multi-byte
1162
+ // character, which would decode as U+FFFD; the offset actually served is
1163
+ // the boundary at or before it, and it is what the response reports back
1164
+ // as `offset` (issue #38).
1165
+ const offset = alignStartToCharBoundary(bytes, args.offset ?? 0);
999
1166
  // Page size only: a stashed result carries no connector identity, so
1000
1167
  // get_result keeps the deployment-wide default when none is requested.
1001
1168
  // Both sides are validated by now — the argument above, `globalCap` at
@@ -1103,6 +1270,17 @@ export function createMetaTools(
1103
1270
  const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
1104
1271
  try {
1105
1272
  const status = await connector.startAuth(ctx, { force: args.force });
1273
+ // startAuth just spoke to the downstream about this exact credential, so
1274
+ // its answer replaces any older liveness verdict — including the stale
1275
+ // `auth_required` that sent the agent here, once it reports ok.
1276
+ await registry.recordCredentialHealth(connector.id, {
1277
+ state: status.state,
1278
+ checkedAt: new Date().toISOString(),
1279
+ ...(status.message ? { message: status.message } : {}),
1280
+ ...(status.authorizationUrl
1281
+ ? { authorizationUrl: status.authorizationUrl }
1282
+ : {}),
1283
+ });
1106
1284
  if (status.state === "auth_required" && !status.authorizationUrl) {
1107
1285
  // auth_required with nothing to open is a dead end for the operator.
1108
1286
  return errorResult(
@@ -1143,7 +1321,7 @@ const CALL_DESC =
1143
1321
  const CALL_DESTRUCTIVE_DESC =
1144
1322
  "Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. 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.";
1145
1323
  const GET_RESULT_DESC =
1146
- "Page a truncated result stashed by call_tool/batch_call. 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). Unknown/expired id is an error.";
1324
+ "Page a truncated result stashed by call_tool/batch_call. 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.";
1147
1325
  const BATCH_DESC =
1148
1326
  "Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call.";
1149
1327
  const AUTHORIZE_DESC =
@@ -1208,14 +1386,12 @@ export function registerMetaTools(
1208
1386
  registry: RegistryView,
1209
1387
  ctx: {
1210
1388
  baseUrl: string;
1211
- maxResultBytes?: number;
1212
1389
  defaultToolTimeoutMs?: number;
1213
1390
  probeTimeoutMs?: number;
1214
1391
  activity?: ActivityRequestContext;
1215
1392
  },
1216
1393
  ): void {
1217
1394
  const mt = createMetaTools(registry, ctx.baseUrl, {
1218
- maxResultBytes: ctx.maxResultBytes,
1219
1395
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1220
1396
  probeTimeoutMs: ctx.probeTimeoutMs,
1221
1397
  activity: ctx.activity,
@@ -1344,10 +1520,11 @@ export function registerMetaTools(
1344
1520
  description: GET_RESULT_DESC,
1345
1521
  inputSchema: {
1346
1522
  id: z.string(),
1347
- offset: z.number().int().nonnegative().optional(),
1348
- // Same rule as isValidMaxResultBytes, expressed for the wire: sharing
1349
- // the floor constant keeps the schema from drifting away from the
1350
- // in-handler check if MIN_MAX_RESULT_BYTES ever moves.
1523
+ // Both bounds are the shared rules (isValidResultOffset,
1524
+ // isValidMaxResultBytes) expressed for the wire: spelling them against
1525
+ // the same constants keeps the schema from drifting away from the
1526
+ // in-handler checks if either floor ever moves.
1527
+ offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
1351
1528
  maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
1352
1529
  },
1353
1530
  annotations: READ_ONLY_LOCAL,