@zackbart/connecta 0.4.1 → 0.6.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 (85) hide show
  1. package/CHANGELOG.md +527 -0
  2. package/README.md +83 -7
  3. package/SECURITY.md +10 -6
  4. package/dist/activity.d.ts +8 -0
  5. package/dist/activity.d.ts.map +1 -1
  6. package/dist/activity.js +1 -0
  7. package/dist/activity.js.map +1 -1
  8. package/dist/auth/bearer.d.ts +10 -3
  9. package/dist/auth/bearer.d.ts.map +1 -1
  10. package/dist/auth/bearer.js +21 -0
  11. package/dist/auth/bearer.js.map +1 -1
  12. package/dist/auth/clerk.d.ts +26 -1
  13. package/dist/auth/clerk.d.ts.map +1 -1
  14. package/dist/auth/clerk.js +161 -4
  15. package/dist/auth/clerk.js.map +1 -1
  16. package/dist/connectors/api.d.ts +13 -0
  17. package/dist/connectors/api.d.ts.map +1 -1
  18. package/dist/connectors/api.js +2 -0
  19. package/dist/connectors/api.js.map +1 -1
  20. package/dist/connectors/remote-mcp.d.ts +13 -0
  21. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  22. package/dist/connectors/remote-mcp.js +10 -0
  23. package/dist/connectors/remote-mcp.js.map +1 -1
  24. package/dist/credential-health.d.ts +212 -0
  25. package/dist/credential-health.d.ts.map +1 -0
  26. package/dist/credential-health.js +535 -0
  27. package/dist/credential-health.js.map +1 -0
  28. package/dist/execute.d.ts +4 -4
  29. package/dist/execute.d.ts.map +1 -1
  30. package/dist/execute.js +16 -4
  31. package/dist/execute.js.map +1 -1
  32. package/dist/index.d.ts +77 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +112 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/meta-tools.d.ts +76 -7
  37. package/dist/meta-tools.d.ts.map +1 -1
  38. package/dist/meta-tools.js +328 -98
  39. package/dist/meta-tools.js.map +1 -1
  40. package/dist/registry.d.ts +245 -2
  41. package/dist/registry.d.ts.map +1 -1
  42. package/dist/registry.js +377 -27
  43. package/dist/registry.js.map +1 -1
  44. package/dist/server.d.ts +7 -1
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +342 -27
  47. package/dist/server.js.map +1 -1
  48. package/dist/skills.d.ts +53 -2
  49. package/dist/skills.d.ts.map +1 -1
  50. package/dist/skills.js +162 -2
  51. package/dist/skills.js.map +1 -1
  52. package/dist/timeout.d.ts +16 -0
  53. package/dist/timeout.d.ts.map +1 -0
  54. package/dist/timeout.js +38 -0
  55. package/dist/timeout.js.map +1 -0
  56. package/dist/toolkits.d.ts +138 -0
  57. package/dist/toolkits.d.ts.map +1 -0
  58. package/dist/toolkits.js +319 -0
  59. package/dist/toolkits.js.map +1 -0
  60. package/dist/types.d.ts +90 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/ui.d.ts +63 -0
  63. package/dist/ui.d.ts.map +1 -1
  64. package/dist/ui.js +176 -11
  65. package/dist/ui.js.map +1 -1
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +5 -2
  69. package/src/activity.ts +9 -0
  70. package/src/auth/bearer.ts +35 -1
  71. package/src/auth/clerk.ts +202 -5
  72. package/src/connectors/api.ts +15 -0
  73. package/src/connectors/remote-mcp.ts +24 -0
  74. package/src/credential-health.ts +736 -0
  75. package/src/execute.ts +32 -8
  76. package/src/index.ts +226 -2
  77. package/src/meta-tools.ts +397 -119
  78. package/src/registry.ts +540 -29
  79. package/src/server.ts +431 -25
  80. package/src/skills.ts +185 -2
  81. package/src/timeout.ts +49 -0
  82. package/src/toolkits.ts +450 -0
  83. package/src/types.ts +96 -2
  84. package/src/ui.ts +190 -11
  85. package/src/version.ts +1 -1
package/src/meta-tools.ts CHANGED
@@ -13,8 +13,25 @@ import {
13
13
  messageLooksRetryable,
14
14
  type CallErrorDetails,
15
15
  } from "./errors.js";
16
- import type { Registry } from "./registry.js";
17
- import { AVAILABLE_SKILLS } from "./skills.js";
16
+ import {
17
+ isValidMaxResultBytes,
18
+ MIN_MAX_RESULT_BYTES,
19
+ resolveMaxResultBytes,
20
+ type RegistryView,
21
+ } from "./registry.js";
22
+ import {
23
+ connectorGuide,
24
+ connectorSkillName,
25
+ hasConnectorGuides,
26
+ listSkills,
27
+ resolveSkill,
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";
18
35
  import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
19
36
 
20
37
  interface TextContent {
@@ -64,50 +81,6 @@ type ErrorDetails = CallErrorDetails;
64
81
  */
65
82
  export const MAX_RETRY_BACKOFF_MS = 10_000;
66
83
 
67
- /** A finite, positive integer number of milliseconds, or undefined. */
68
- function normalizeTimeoutMs(value: number | undefined): number | undefined {
69
- if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
70
- return undefined;
71
- }
72
- return Math.max(1, Math.trunc(value));
73
- }
74
-
75
- /**
76
- * Generous default bound for a single downstream probe/catalog call in the
77
- * list/search/describe fan-out. High enough to trip only on a pathological
78
- * hang, not a realistically slow probe.
79
- */
80
- const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
81
-
82
- /**
83
- * Reject `promise` after `ms` if it has not settled, so one hung downstream
84
- * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
85
- * the registry probe methods take no AbortSignal, so the underlying fetch is
86
- * NOT cancelled and keeps running in the background. Real cancellation
87
- * (AbortSignal plumbed through the registry) is a deferred follow-up.
88
- */
89
- function withTimeout<T>(
90
- promise: Promise<T>,
91
- ms: number,
92
- label: string,
93
- ): Promise<T> {
94
- return new Promise<T>((resolve, reject) => {
95
- const timer = setTimeout(() => {
96
- reject(new Error(`${label} timed out after ${ms}ms`));
97
- }, ms);
98
- promise.then(
99
- (value) => {
100
- clearTimeout(timer);
101
- resolve(value);
102
- },
103
- (err) => {
104
- clearTimeout(timer);
105
- reject(err);
106
- },
107
- );
108
- });
109
- }
110
-
111
84
  /**
112
85
  * How long to wait before the next attempt, or `undefined` for "don't retry".
113
86
  *
@@ -142,6 +115,48 @@ function isContinuationByte(b: number): boolean {
142
115
  return (b & 0xc0) === 0x80;
143
116
  }
144
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
+
145
160
  /**
146
161
  * Move a byte `end` back to the nearest UTF-8 codepoint boundary in
147
162
  * `(offset, total]`, so decoding `bytes[offset, end)` never splits a codepoint
@@ -150,19 +165,28 @@ function isContinuationByte(b: number): boolean {
150
165
  * forward to the end of that codepoint instead so paging always advances.
151
166
  * Assumes `offset` is itself a codepoint boundary (offsets are the prior
152
167
  * `nextOffset`, which this function guarantees, and 0 is always a boundary).
168
+ *
169
+ * The return is always `> offset` while `offset < total`, whatever `end` is
170
+ * asked for. That is the belt-and-braces half of issue #32: cap validation
171
+ * keeps an empty window from arising in the first place, and this keeps an
172
+ * empty window from turning into a `nextOffset === offset` paging loop if one
173
+ * ever does. Exported for direct testing of that invariant.
153
174
  */
154
- function alignEndToCharBoundary(
175
+ export function alignEndToCharBoundary(
155
176
  bytes: Uint8Array,
156
177
  offset: number,
157
178
  end: number,
158
179
  total: number,
159
180
  ): number {
160
181
  if (end >= total) return total;
161
- let e = end;
182
+ // A window that reaches no further than `offset` yields no bytes and no
183
+ // progress; widen it to one byte and let the codepoint walk below finish it.
184
+ const wanted = Math.max(end, offset + 1);
185
+ let e = wanted;
162
186
  while (e > offset && isContinuationByte(bytes[e])) e--;
163
187
  if (e === offset) {
164
188
  // Window is narrower than the codepoint at `offset`; take the whole thing.
165
- e = end;
189
+ e = wanted;
166
190
  while (e < total && isContinuationByte(bytes[e])) e++;
167
191
  }
168
192
  return e;
@@ -220,39 +244,88 @@ function applyFieldsToContent(
220
244
 
221
245
  // --- result-size guard + get_result (feature 1) ---------------------------
222
246
 
223
- function contentBytes(content: TextContent[]): number {
224
- let n = 0;
225
- for (const b of content)
226
- if (b.type === "text") n += enc.encode(b.text).length;
227
- 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
+ };
228
289
  }
229
290
 
230
291
  /**
231
292
  * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
232
- * full text under `result:<uuid>` (ttl 900s) and return the first `cap` bytes
233
- * 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.
234
296
  */
235
- async function guardText(
297
+ async function guardEncoded(
236
298
  text: string,
299
+ bytes: Uint8Array,
237
300
  results: KVStorage,
238
301
  cap: number,
239
302
  ): Promise<ToolResult> {
240
- const bytes = enc.encode(text);
241
303
  if (bytes.length <= cap) {
242
304
  return { content: [{ type: "text", text }] };
243
305
  }
244
- const id = crypto.randomUUID();
245
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
306
+ const notice = await stashResult(text, results, bytes.length);
246
307
  const head = dec.decode(
247
308
  bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)),
248
309
  );
249
- const notice = JSON.stringify({
250
- truncated: true,
251
- resultId: id,
252
- totalBytes: bytes.length,
253
- hint: "use get_result {id, offset} to page, or re-call with fields to select less",
254
- });
255
- 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);
256
329
  }
257
330
 
258
331
  /** Store an oversized JSON value and replace it with a page handle. */
@@ -261,17 +334,54 @@ async function guardValue(
261
334
  results: KVStorage,
262
335
  cap: number,
263
336
  ): Promise<unknown> {
264
- const text = JSON.stringify(value, null, 2) ?? String(value);
337
+ const text = serializeResultText(value);
265
338
  const bytes = enc.encode(text);
266
339
  if (bytes.length <= cap) return value;
267
- const id = crypto.randomUUID();
268
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
269
- return {
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
- };
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) }] };
275
385
  }
276
386
 
277
387
  // --- compact schema rendering (feature 3a) --------------------------------
@@ -309,7 +419,12 @@ export interface CallArgs {
309
419
  }
310
420
  export interface GetResultArgs {
311
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
+ */
312
426
  offset?: number;
427
+ /** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
313
428
  maxBytes?: number;
314
429
  }
315
430
  export interface BatchCall {
@@ -338,16 +453,19 @@ export interface SkillArgs {
338
453
 
339
454
  /**
340
455
  * The nine meta-tool handlers over a registry. Exported for direct testing;
341
- * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
342
- * overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
456
+ * registerMetaTools() wires them onto an McpServer. `opts.defaultToolTimeoutMs`
343
457
  * supplies a deadline for calls that don't carry one. (execute_code, the
344
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).
345
464
  */
346
465
  export function createMetaTools(
347
- registry: Registry,
466
+ registry: RegistryView,
348
467
  baseUrl: string,
349
468
  opts: {
350
- maxResultBytes?: number;
351
469
  /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
352
470
  defaultToolTimeoutMs?: number;
353
471
  /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
@@ -355,7 +473,8 @@ export function createMetaTools(
355
473
  activity?: ActivityRequestContext;
356
474
  } = {},
357
475
  ) {
358
- const cap = opts.maxResultBytes ?? registry.maxResultBytes;
476
+ // Already normalized and warned about at registry construction.
477
+ const globalCap = registry.maxResultBytes;
359
478
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
360
479
  const probeTimeoutMs =
361
480
  normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
@@ -442,6 +561,16 @@ export function createMetaTools(
442
561
  );
443
562
  }
444
563
  const results = registry.resultsStorage();
564
+ // Result-size cap for THIS call: the connector's own override wins, then
565
+ // the deployment-wide value, then the built-in default (already folded
566
+ // into `globalCap`). Resolved per call so one batch_call can mix a
567
+ // tight-capped connector with siblings on the global cap. An override the
568
+ // registry already warned about at startup is dropped here, so the
569
+ // connector simply inherits `globalCap`.
570
+ const cap = resolveMaxResultBytes(
571
+ resolved.connector.maxResultBytes,
572
+ globalCap,
573
+ );
445
574
  const fields = call.fields && call.fields.length > 0 ? call.fields : null;
446
575
  // An explicit per-call deadline always wins; the config default only fills
447
576
  // the gap, and stays off entirely when the deployment sets none.
@@ -458,6 +587,20 @@ export function createMetaTools(
458
587
  ).find((tool) => tool.name === resolved.toolName);
459
588
  } catch (err) {
460
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);
461
604
  // classifyCallError so a typed auth_required thrown while listing tools
462
605
  // (e.g. a revoked downstream OAuth grant) keeps its code.
463
606
  return failed(classifyCallError(err, "catalog_lookup_failed"));
@@ -590,16 +733,7 @@ export function createMetaTools(
590
733
  if (resolved.connector.kind === "mcp") {
591
734
  let content = mr?.content ?? [];
592
735
  if (fields) content = applyFieldsToContent(content, fields);
593
- let toolResult: ToolResult;
594
- if (contentBytes(content) > cap) {
595
- toolResult = await guardText(
596
- JSON.stringify(content, null, 2),
597
- results,
598
- cap,
599
- );
600
- } else {
601
- toolResult = { content };
602
- }
736
+ const toolResult = await guardContent(content, results, cap);
603
737
  resultProcessingMs += Date.now() - processingStarted;
604
738
  record("success");
605
739
  return {
@@ -611,7 +745,7 @@ export function createMetaTools(
611
745
  }
612
746
  const value = fields ? applyFields(result, fields) : result;
613
747
  const toolResult = await guardText(
614
- JSON.stringify(value, null, 2),
748
+ serializeResultText(value),
615
749
  results,
616
750
  cap,
617
751
  );
@@ -632,6 +766,7 @@ export function createMetaTools(
632
766
 
633
767
  return {
634
768
  async skills(args: SkillArgs = {}): Promise<ToolResult> {
769
+ const connectors = registry.listConnectors();
635
770
  if (!args.name) {
636
771
  return {
637
772
  content: [
@@ -639,19 +774,15 @@ export function createMetaTools(
639
774
  type: "text",
640
775
  text:
641
776
  'Available skills. Fetch one with skills({ name: "<name>" }).\n\n' +
642
- AVAILABLE_SKILLS.map(
643
- (skill) => `- \`${skill.name}\` — ${skill.description}`,
644
- ).join("\n"),
777
+ listSkills(connectors)
778
+ .map((skill) => `- \`${skill.name}\` — ${skill.description}`)
779
+ .join("\n"),
645
780
  },
646
781
  ],
647
782
  };
648
783
  }
649
- const skill = AVAILABLE_SKILLS.find((item) => item.name === args.name);
650
- if (!skill) {
651
- return errorResult(
652
- `Unknown skill "${args.name}". Available: ${AVAILABLE_SKILLS.map((item) => item.name).join(", ")}.`,
653
- );
654
- }
784
+ const skill = resolveSkill(args.name, connectors);
785
+ if (!skill.found) return errorResult(skill.message);
655
786
  return { content: [{ type: "text", text: skill.content }] };
656
787
  },
657
788
 
@@ -659,9 +790,9 @@ export function createMetaTools(
659
790
  const probe = args.probe ?? true;
660
791
  const out = await Promise.all(
661
792
  registry.listConnectors().map(async (c) => {
662
- const checkedAt = new Date().toISOString();
663
793
  const statusStarted = Date.now();
664
794
  const observed = registry.healthFor(c.id);
795
+ const verdict = await registry.credentialHealthFor(c.id);
665
796
  let status:
666
797
  | ConnectorStatus
667
798
  | { state: "ok" | "error" | "unknown"; message?: string };
@@ -678,18 +809,76 @@ export function createMetaTools(
678
809
  // hanging the whole list_connectors call.
679
810
  status = { state: "error", message: msg(err) };
680
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
+ };
681
832
  } else {
833
+ // "error" comes from THIS view's own observations — a sibling
834
+ // toolkit's failure is not this session's experience — while
835
+ // ok/unknown may lean on the deployment-wide success signal, since
836
+ // "the connector answers at all" is a fact about the connector.
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);
682
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.
683
850
  state:
684
- observed?.consecutiveFailures &&
685
- observed.consecutiveFailures > 0
686
- ? ("error" as const)
687
- : observed?.lastSuccessAt || c.kind === "api"
688
- ? ("ok" as const)
689
- : ("unknown" as const),
851
+ derived === "unknown" && verdict?.state === "ok"
852
+ ? ("ok" as const)
853
+ : derived,
690
854
  ...(observed?.lastError ? { message: observed.lastError } : {}),
691
855
  };
692
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
+ }
693
882
  let tools = registry.peekTools(c.id);
694
883
  // An auth_required status may have just started OAuth. A second
695
884
  // listTools probe would overwrite its state/verifier while returning
@@ -709,6 +898,9 @@ export function createMetaTools(
709
898
  }
710
899
  const latencyMs = Date.now() - statusStarted;
711
900
  const latestObserved = registry.healthFor(c.id);
901
+ const credentialCheck = probe
902
+ ? await registry.credentialHealthFor(c.id)
903
+ : verdict;
712
904
  return {
713
905
  id: c.id,
714
906
  ...(c.title ? { title: c.title } : {}),
@@ -719,6 +911,7 @@ export function createMetaTools(
719
911
  latencyMs,
720
912
  probe,
721
913
  ...(latestObserved ?? observed ?? {}),
914
+ ...(credentialCheck ? { credentialCheck } : {}),
722
915
  ...("authorizationUrl" in status && status.authorizationUrl
723
916
  ? { authorizationUrl: status.authorizationUrl }
724
917
  : {}),
@@ -742,6 +935,7 @@ export function createMetaTools(
742
935
  connectorId: string;
743
936
  connectorTitle?: string;
744
937
  connectorDescription?: string;
938
+ connectorGuideSkill?: string;
745
939
  tool: ToolDef;
746
940
  score: number;
747
941
  order: number;
@@ -764,6 +958,9 @@ export function createMetaTools(
764
958
  connectorId: c.id,
765
959
  connectorTitle: c.title,
766
960
  connectorDescription: c.description,
961
+ ...(connectorGuide(c)
962
+ ? { connectorGuideSkill: connectorSkillName(c.id) }
963
+ : {}),
767
964
  tool: ranked.tool,
768
965
  score: ranked.score,
769
966
  order: orderBase + ranked.order,
@@ -778,6 +975,8 @@ export function createMetaTools(
778
975
  id: string;
779
976
  title?: string;
780
977
  description?: string;
978
+ /** Skill name of this connector's usage guide, when it has one. */
979
+ guide?: string;
781
980
  tools: Array<{
782
981
  name: string;
783
982
  address: string;
@@ -795,6 +994,9 @@ export function createMetaTools(
795
994
  id: match.connectorId,
796
995
  ...(match.connectorTitle ? { title: match.connectorTitle } : {}),
797
996
  description: match.connectorDescription,
997
+ ...(match.connectorGuideSkill
998
+ ? { guide: match.connectorGuideSkill }
999
+ : {}),
798
1000
  tools: [],
799
1001
  };
800
1002
  byConnector.set(match.connectorId, group);
@@ -899,6 +1101,9 @@ export function createMetaTools(
899
1101
  tool.description,
900
1102
  args.fullDescriptions === true,
901
1103
  ),
1104
+ ...(connectorGuide(resolved.connector)
1105
+ ? { guide: connectorSkillName(resolved.connector.id) }
1106
+ : {}),
902
1107
  inputSchema: format === "json" ? schema : compactSchema(schema),
903
1108
  ...(tool.outputSchema
904
1109
  ? {
@@ -925,6 +1130,26 @@ export function createMetaTools(
925
1130
  },
926
1131
 
927
1132
  async getResult(args: GetResultArgs): Promise<ToolResult> {
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
1136
+ // in-process callers of createMetaTools — which have no schema in front
1137
+ // of them — and to keep the rules true of the handler on its own terms.
1138
+ if (
1139
+ args.maxBytes !== undefined &&
1140
+ !isValidMaxResultBytes(args.maxBytes)
1141
+ ) {
1142
+ return errorResult(
1143
+ `Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
1144
+ `>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`,
1145
+ );
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
+ }
928
1153
  const results = registry.resultsStorage();
929
1154
  const stored = await results.get(`result:${args.id}`);
930
1155
  if (stored === null || stored === undefined) {
@@ -932,8 +1157,17 @@ export function createMetaTools(
932
1157
  }
933
1158
  const bytes = enc.encode(stored);
934
1159
  const total = bytes.length;
935
- const offset = Math.max(0, Math.trunc(args.offset ?? 0));
936
- const maxBytes = args.maxBytes ?? cap;
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);
1166
+ // Page size only: a stashed result carries no connector identity, so
1167
+ // get_result keeps the deployment-wide default when none is requested.
1168
+ // Both sides are validated by now — the argument above, `globalCap` at
1169
+ // intake — so `offset + maxBytes` always reaches past `offset`.
1170
+ const maxBytes = args.maxBytes ?? globalCap;
937
1171
  // Align the slice end to a codepoint boundary so a multi-byte char is
938
1172
  // never split across pages (which would emit U+FFFD on both sides).
939
1173
  // `nextOffset` is this aligned end, so it is a valid boundary for the
@@ -1036,6 +1270,17 @@ export function createMetaTools(
1036
1270
  const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
1037
1271
  try {
1038
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
+ });
1039
1284
  if (status.state === "auth_required" && !status.authorizationUrl) {
1040
1285
  // auth_required with nothing to open is a dead end for the operator.
1041
1286
  return errorResult(
@@ -1076,7 +1321,7 @@ const CALL_DESC =
1076
1321
  const CALL_DESTRUCTIVE_DESC =
1077
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.";
1078
1323
  const GET_RESULT_DESC =
1079
- "Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. 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.";
1080
1325
  const BATCH_DESC =
1081
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.";
1082
1327
  const AUTHORIZE_DESC =
@@ -1084,6 +1329,37 @@ const AUTHORIZE_DESC =
1084
1329
  const SKILLS_DESC =
1085
1330
  '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.';
1086
1331
 
1332
+ /**
1333
+ * Sentences appended to a meta-tool description only when this connection
1334
+ * actually has connector guides. Tool descriptions are always-loaded context,
1335
+ * so a deployment with no guides gets every base description unchanged rather
1336
+ * than paying for text about a feature it does not use.
1337
+ *
1338
+ * Registration is per connection and reads the connection's own registry view,
1339
+ * so under a toolkit these sentences reflect the SCOPED connector set: a scoped
1340
+ * session whose connectors carry no guides sees the base descriptions, and
1341
+ * never learns from a tool description that guides exist out of scope.
1342
+ */
1343
+ const GUIDE_NOTES = {
1344
+ skills:
1345
+ ' skills({}) also lists this deployment\'s per-connector usage guides as "connector:<connectorId>"; fetch the guide for a connector before working with it for the first time.',
1346
+ search:
1347
+ " A connector group carrying `guide` has a usage guide; fetch it with skills({ name: <guide> }).",
1348
+ describe:
1349
+ " An entry carrying `guide` belongs to a connector with a usage guide; fetch it with skills({ name: <guide> }).",
1350
+ } as const;
1351
+
1352
+ /** `base`, plus its guide note when any VISIBLE connector carries a guide. */
1353
+ function describedFor(
1354
+ registry: RegistryView,
1355
+ base: string,
1356
+ note: keyof typeof GUIDE_NOTES,
1357
+ ): string {
1358
+ return hasConnectorGuides(registry.listConnectors())
1359
+ ? base + GUIDE_NOTES[note]
1360
+ : base;
1361
+ }
1362
+
1087
1363
  /**
1088
1364
  * Connecta refuses downstream tools that are not explicitly annotated
1089
1365
  * read-only, so its own meta-tools must carry the same hints — otherwise a
@@ -1107,17 +1383,15 @@ const READ_ONLY_LOCAL = {
1107
1383
  /** Register the nine meta-tools onto an McpServer instance. */
1108
1384
  export function registerMetaTools(
1109
1385
  server: McpServer,
1110
- registry: Registry,
1386
+ registry: RegistryView,
1111
1387
  ctx: {
1112
1388
  baseUrl: string;
1113
- maxResultBytes?: number;
1114
1389
  defaultToolTimeoutMs?: number;
1115
1390
  probeTimeoutMs?: number;
1116
1391
  activity?: ActivityRequestContext;
1117
1392
  },
1118
1393
  ): void {
1119
1394
  const mt = createMetaTools(registry, ctx.baseUrl, {
1120
- maxResultBytes: ctx.maxResultBytes,
1121
1395
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
1122
1396
  probeTimeoutMs: ctx.probeTimeoutMs,
1123
1397
  activity: ctx.activity,
@@ -1126,7 +1400,7 @@ export function registerMetaTools(
1126
1400
  server.registerTool(
1127
1401
  "skills",
1128
1402
  {
1129
- description: SKILLS_DESC,
1403
+ description: describedFor(registry, SKILLS_DESC, "skills"),
1130
1404
  inputSchema: { name: z.string().optional() },
1131
1405
  annotations: {
1132
1406
  readOnlyHint: true,
@@ -1151,7 +1425,7 @@ export function registerMetaTools(
1151
1425
  server.registerTool(
1152
1426
  "search_tools",
1153
1427
  {
1154
- description: SEARCH_DESC,
1428
+ description: describedFor(registry, SEARCH_DESC, "search"),
1155
1429
  inputSchema: {
1156
1430
  query: z.string().optional(),
1157
1431
  connector: z.string().optional(),
@@ -1168,7 +1442,7 @@ export function registerMetaTools(
1168
1442
  server.registerTool(
1169
1443
  "describe_tools",
1170
1444
  {
1171
- description: DESCRIBE_DESC,
1445
+ description: describedFor(registry, DESCRIBE_DESC, "describe"),
1172
1446
  inputSchema: {
1173
1447
  addresses: z.array(z.string()),
1174
1448
  format: z.enum(["compact", "json"]).optional(),
@@ -1246,8 +1520,12 @@ export function registerMetaTools(
1246
1520
  description: GET_RESULT_DESC,
1247
1521
  inputSchema: {
1248
1522
  id: z.string(),
1249
- offset: z.number().int().nonnegative().optional(),
1250
- maxBytes: z.number().int().positive().optional(),
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(),
1528
+ maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
1251
1529
  },
1252
1530
  annotations: READ_ONLY_LOCAL,
1253
1531
  },