@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
@@ -3,7 +3,10 @@ import { compactSchema, rankTools, summarizeDescription } from "./catalog.js";
3
3
  import { recordToolActivity, } from "./activity.js";
4
4
  import { unwrapMcpResult } from "./mcp-result.js";
5
5
  import { classifyCallError, ConnectorCallError, messageLooksRetryable, } from "./errors.js";
6
- import { AVAILABLE_SKILLS } from "./skills.js";
6
+ import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
7
+ import { connectorGuide, connectorSkillName, hasConnectorGuides, listSkills, resolveSkill, } from "./skills.js";
8
+ import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, withTimeout, } from "./timeout.js";
9
+ import { credentialVerdictApplies } from "./credential-health.js";
7
10
  const RESULT_TTL_SECONDS = 900;
8
11
  export function jsonResult(obj) {
9
12
  return {
@@ -32,40 +35,6 @@ const dec = new TextDecoder();
32
35
  * decides when to re-issue.
33
36
  */
34
37
  export const MAX_RETRY_BACKOFF_MS = 10_000;
35
- /** A finite, positive integer number of milliseconds, or undefined. */
36
- function normalizeTimeoutMs(value) {
37
- if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
38
- return undefined;
39
- }
40
- return Math.max(1, Math.trunc(value));
41
- }
42
- /**
43
- * Generous default bound for a single downstream probe/catalog call in the
44
- * list/search/describe fan-out. High enough to trip only on a pathological
45
- * hang, not a realistically slow probe.
46
- */
47
- const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
48
- /**
49
- * Reject `promise` after `ms` if it has not settled, so one hung downstream
50
- * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
51
- * the registry probe methods take no AbortSignal, so the underlying fetch is
52
- * NOT cancelled and keeps running in the background. Real cancellation
53
- * (AbortSignal plumbed through the registry) is a deferred follow-up.
54
- */
55
- function withTimeout(promise, ms, label) {
56
- return new Promise((resolve, reject) => {
57
- const timer = setTimeout(() => {
58
- reject(new Error(`${label} timed out after ${ms}ms`));
59
- }, ms);
60
- promise.then((value) => {
61
- clearTimeout(timer);
62
- resolve(value);
63
- }, (err) => {
64
- clearTimeout(timer);
65
- reject(err);
66
- });
67
- });
68
- }
69
38
  /**
70
39
  * How long to wait before the next attempt, or `undefined` for "don't retry".
71
40
  *
@@ -94,6 +63,43 @@ function errorDetails(code, message) {
94
63
  function isContinuationByte(b) {
95
64
  return (b & 0xc0) === 0x80;
96
65
  }
66
+ /** Smallest accepted `get_result` byte offset. */
67
+ export const MIN_RESULT_OFFSET = 0;
68
+ /**
69
+ * The one definition of a usable `get_result` offset: a whole number of bytes
70
+ * at or past {@link MIN_RESULT_OFFSET}. Shared by the registered zod schema and
71
+ * the handler's own check, the way `isValidMaxResultBytes` is shared across the
72
+ * cap's intake points (issue #32) — so a value valid at the wire is valid in
73
+ * process, and the two cannot drift.
74
+ *
75
+ * Everything else is rejected rather than coerced, because coercion is how an
76
+ * out-of-domain offset used to void a result silently: `Math.max(0, NaN)` is
77
+ * `NaN`, which slices to nothing, serializes as `"offset": null`, and reports
78
+ * no `nextOffset` — a caller sees a successful, empty result instead of an
79
+ * error. An offset past the end of the payload stays legal: it is a whole
80
+ * number of bytes, and it answers with an empty final page.
81
+ */
82
+ export function isValidResultOffset(value) {
83
+ return Number.isInteger(value) && value >= MIN_RESULT_OFFSET;
84
+ }
85
+ /**
86
+ * Move a byte `offset` back to the nearest UTF-8 codepoint boundary in
87
+ * `[0, offset]`, so decoding from it never starts mid-character (which emits
88
+ * U+FFFD for the severed tail).
89
+ *
90
+ * Backwards, never forwards: re-serving a few bytes the caller already has is
91
+ * recoverable, silently skipping the rest of a character is not. Offsets the
92
+ * server itself produced (`nextOffset`) are already boundaries and come back
93
+ * unchanged, so this only moves an offset a client computed on its own
94
+ * (issue #38). An offset at or past `bytes.length` is left alone — there is no
95
+ * character there to split.
96
+ */
97
+ export function alignStartToCharBoundary(bytes, offset) {
98
+ let o = offset;
99
+ while (o > 0 && isContinuationByte(bytes[o]))
100
+ o--;
101
+ return o;
102
+ }
97
103
  /**
98
104
  * Move a byte `end` back to the nearest UTF-8 codepoint boundary in
99
105
  * `(offset, total]`, so decoding `bytes[offset, end)` never splits a codepoint
@@ -102,16 +108,25 @@ function isContinuationByte(b) {
102
108
  * forward to the end of that codepoint instead so paging always advances.
103
109
  * Assumes `offset` is itself a codepoint boundary (offsets are the prior
104
110
  * `nextOffset`, which this function guarantees, and 0 is always a boundary).
111
+ *
112
+ * The return is always `> offset` while `offset < total`, whatever `end` is
113
+ * asked for. That is the belt-and-braces half of issue #32: cap validation
114
+ * keeps an empty window from arising in the first place, and this keeps an
115
+ * empty window from turning into a `nextOffset === offset` paging loop if one
116
+ * ever does. Exported for direct testing of that invariant.
105
117
  */
106
- function alignEndToCharBoundary(bytes, offset, end, total) {
118
+ export function alignEndToCharBoundary(bytes, offset, end, total) {
107
119
  if (end >= total)
108
120
  return total;
109
- let e = end;
121
+ // A window that reaches no further than `offset` yields no bytes and no
122
+ // progress; widen it to one byte and let the codepoint walk below finish it.
123
+ const wanted = Math.max(end, offset + 1);
124
+ let e = wanted;
110
125
  while (e > offset && isContinuationByte(bytes[e]))
111
126
  e--;
112
127
  if (e === offset) {
113
128
  // Window is narrower than the codepoint at `offset`; take the whole thing.
114
- e = end;
129
+ e = wanted;
115
130
  while (e < total && isContinuationByte(bytes[e]))
116
131
  e++;
117
132
  }
@@ -164,58 +179,128 @@ function applyFieldsToContent(content, fields) {
164
179
  });
165
180
  }
166
181
  // --- result-size guard + get_result (feature 1) ---------------------------
167
- function contentBytes(content) {
168
- let n = 0;
169
- for (const b of content)
170
- if (b.type === "text")
171
- n += enc.encode(b.text).length;
172
- return n;
182
+ /**
183
+ * The one serialization every result guard measures, stashes, and pages: JSON
184
+ * text for whatever JSON can represent, and `String(value)` for the returns
185
+ * JSON renders as `undefined` — a handler that returns nothing, a function, or
186
+ * a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
187
+ * returning `undefined` for those, which is how a handler returning `undefined`
188
+ * reached clients as a `{"type":"text"}` block carrying no `text` at all: the
189
+ * size guard measured `enc.encode(undefined)` — the empty string, per the
190
+ * WebIDL default — and emitted the non-string unchanged (issue #42). `null`
191
+ * needs no special case; JSON renders it as `"null"`.
192
+ *
193
+ * Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
194
+ * the three give one answer to the same question. A value JSON cannot serialize
195
+ * at all (a BigInt) still throws, as before, and is reported as a failure.
196
+ */
197
+ export function serializeResultText(value) {
198
+ const serialized = JSON.stringify(value, null, 2);
199
+ return serialized === undefined ? String(value) : serialized;
173
200
  }
174
201
  /**
175
- * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
176
- * full text under `result:<uuid>` (ttl 900s) and return the first `cap` bytes
177
- * followed by a JSON truncation notice pointing at get_result.
202
+ * Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
203
+ * truncation notice every over-cap path hands back.
178
204
  */
179
- async function guardText(text, results, cap) {
180
- const bytes = enc.encode(text);
181
- if (bytes.length <= cap) {
182
- return { content: [{ type: "text", text }] };
183
- }
205
+ async function stashResult(text, results, totalBytes) {
184
206
  const id = crypto.randomUUID();
185
207
  await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
186
- const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
187
- const notice = JSON.stringify({
208
+ return {
188
209
  truncated: true,
189
210
  resultId: id,
190
- totalBytes: bytes.length,
211
+ totalBytes,
191
212
  hint: "use get_result {id, offset} to page, or re-call with fields to select less",
192
- });
193
- return { content: [{ type: "text", text: `${head}\n${notice}` }] };
213
+ };
214
+ }
215
+ /**
216
+ * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
217
+ * full text and return the first `cap` bytes followed by a JSON truncation
218
+ * notice pointing at get_result. `bytes` is `text` already encoded, so a caller
219
+ * that had to measure it to make this decision doesn't encode it twice.
220
+ */
221
+ async function guardEncoded(text, bytes, results, cap) {
222
+ if (bytes.length <= cap) {
223
+ return { content: [{ type: "text", text }] };
224
+ }
225
+ const notice = await stashResult(text, results, bytes.length);
226
+ const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
227
+ return {
228
+ content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
229
+ };
230
+ }
231
+ /** {@link guardEncoded} over a string that has not been measured yet. */
232
+ async function guardText(text, results, cap) {
233
+ // `JSON.stringify`'s type says `string` where its behavior says `string |
234
+ // undefined`, so TypeScript alone does not keep a non-string out of here.
235
+ // Normalizing at the door means the size check below always measures exactly
236
+ // the text that is emitted, and no future caller can launder a non-string
237
+ // through it the way issue #42 describes.
238
+ const body = typeof text === "string" ? text : serializeResultText(text);
239
+ return guardEncoded(body, enc.encode(body), results, cap);
194
240
  }
195
241
  /** Store an oversized JSON value and replace it with a page handle. */
196
242
  async function guardValue(value, results, cap) {
197
- const text = JSON.stringify(value, null, 2) ?? String(value);
243
+ const text = serializeResultText(value);
198
244
  const bytes = enc.encode(text);
199
245
  if (bytes.length <= cap)
200
246
  return value;
201
- const id = crypto.randomUUID();
202
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
203
- return {
204
- truncated: true,
205
- resultId: id,
206
- totalBytes: bytes.length,
207
- hint: "use get_result {id, offset} to page, or re-call with fields to select less",
208
- };
247
+ return stashResult(text, results, bytes.length);
248
+ }
249
+ /**
250
+ * Bound a downstream MCP `content` array by `cap`, measuring the serialized
251
+ * envelope — the same string that gets stashed and paged, and the one that
252
+ * counts every block rather than only the text ones.
253
+ *
254
+ * Both halves matter (issue #43). Measuring only text blocks meant an oversized
255
+ * all-image result scored zero bytes and was returned inline unbounded, with no
256
+ * `resultId` to page from; and measuring one string while truncating another
257
+ * left `totalBytes` and the served head describing something the cap was never
258
+ * compared against.
259
+ *
260
+ * Over the cap, what a client gets depends on whether a prefix is usable. An
261
+ * all-text envelope keeps the historical head + notice — a JSON prefix is still
262
+ * readable. An envelope carrying non-text blocks is replaced by the notice
263
+ * alone: the head of a half-written base64 image is of no use to anyone, and
264
+ * cutting one leaves unparseable block structure behind. Either way the full
265
+ * envelope is stashed and pages through `get_result`.
266
+ */
267
+ async function guardContent(content, results, cap) {
268
+ let text;
269
+ try {
270
+ text = JSON.stringify(content, null, 2);
271
+ }
272
+ catch {
273
+ // A block carrying a BigInt or a cycle cannot be serialized, so it cannot
274
+ // be measured, stashed, or paged either — there is nothing this guard could
275
+ // do with it. Pass it through as the old text-only measure did, rather than
276
+ // turning a call that used to succeed into result_processing_failed.
277
+ return { content };
278
+ }
279
+ const bytes = enc.encode(text);
280
+ // Under the cap the downstream blocks pass through untouched, non-text ones
281
+ // included, in their original order.
282
+ if (bytes.length <= cap)
283
+ return { content };
284
+ if (content.every((b) => b.type === "text")) {
285
+ return guardEncoded(text, bytes, results, cap);
286
+ }
287
+ const notice = await stashResult(text, results, bytes.length);
288
+ return { content: [{ type: "text", text: JSON.stringify(notice) }] };
209
289
  }
210
290
  /**
211
291
  * The nine meta-tool handlers over a registry. Exported for direct testing;
212
- * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
213
- * overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
292
+ * registerMetaTools() wires them onto an McpServer. `opts.defaultToolTimeoutMs`
214
293
  * supplies a deadline for calls that don't carry one. (execute_code, the
215
294
  * optional tenth tool, is registered separately by registerExecuteTool.)
295
+ *
296
+ * The deployment-wide result-size cap is read off the registry view rather than
297
+ * passed in: `ConnectaConfig.maxResultBytes` and the per-connector override are
298
+ * the only places a cap is set, so there is one answer to where a deployment
299
+ * sets it (issue #44).
216
300
  */
217
301
  export function createMetaTools(registry, baseUrl, opts = {}) {
218
- const cap = opts.maxResultBytes ?? registry.maxResultBytes;
302
+ // Already normalized and warned about at registry construction.
303
+ const globalCap = registry.maxResultBytes;
219
304
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
220
305
  const probeTimeoutMs = normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
221
306
  // createMetaTools() is called once per inbound MCP request. Sharing this
@@ -276,6 +361,13 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
276
361
  return failed(errorDetails("unknown_address", `Unknown address "${call.address}"`));
277
362
  }
278
363
  const results = registry.resultsStorage();
364
+ // Result-size cap for THIS call: the connector's own override wins, then
365
+ // the deployment-wide value, then the built-in default (already folded
366
+ // into `globalCap`). Resolved per call so one batch_call can mix a
367
+ // tight-capped connector with siblings on the global cap. An override the
368
+ // registry already warned about at startup is dropped here, so the
369
+ // connector simply inherits `globalCap`.
370
+ const cap = resolveMaxResultBytes(resolved.connector.maxResultBytes, globalCap);
279
371
  const fields = call.fields && call.fields.length > 0 ? call.fields : null;
280
372
  // An explicit per-call deadline always wins; the config default only fills
281
373
  // the gap, and stays off entirely when the deployment sets none.
@@ -288,6 +380,20 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
288
380
  }
289
381
  catch (err) {
290
382
  catalogMs += Date.now() - catalogStarted;
383
+ // A connector whose catalog cannot be fetched is as unusable as one whose
384
+ // execution fails, so it feeds health accounting the same way the
385
+ // execution catch below does — otherwise a connector every call_tool
386
+ // fails against (a revoked downstream grant, say) still reads clean from
387
+ // the cheap `list_connectors({ probe: false })` signal.
388
+ //
389
+ // Recorded HERE rather than inside the registry's catalog fetch on
390
+ // purpose: `registry` is this connection's VIEW, so a toolkit-scoped
391
+ // session records into its own log as well as the deployment-wide one,
392
+ // which `Registry.refreshTools` could not reach. A cache hit that avoids
393
+ // a live listTools call therefore records nothing either way — it is not
394
+ // evidence of health, and success stays what it has always been: an
395
+ // actual downstream call that returned.
396
+ registry.recordFailure(resolved.connector.id, Date.now() - started, err);
291
397
  // classifyCallError so a typed auth_required thrown while listing tools
292
398
  // (e.g. a revoked downstream OAuth grant) keeps its code.
293
399
  return failed(classifyCallError(err, "catalog_lookup_failed"));
@@ -388,13 +494,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
388
494
  let content = mr?.content ?? [];
389
495
  if (fields)
390
496
  content = applyFieldsToContent(content, fields);
391
- let toolResult;
392
- if (contentBytes(content) > cap) {
393
- toolResult = await guardText(JSON.stringify(content, null, 2), results, cap);
394
- }
395
- else {
396
- toolResult = { content };
397
- }
497
+ const toolResult = await guardContent(content, results, cap);
398
498
  resultProcessingMs += Date.now() - processingStarted;
399
499
  record("success");
400
500
  return {
@@ -405,7 +505,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
405
505
  };
406
506
  }
407
507
  const value = fields ? applyFields(result, fields) : result;
408
- const toolResult = await guardText(JSON.stringify(value, null, 2), results, cap);
508
+ const toolResult = await guardText(serializeResultText(value), results, cap);
409
509
  resultProcessingMs += Date.now() - processingStarted;
410
510
  record("success");
411
511
  return {
@@ -423,29 +523,31 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
423
523
  }
424
524
  return {
425
525
  async skills(args = {}) {
526
+ const connectors = registry.listConnectors();
426
527
  if (!args.name) {
427
528
  return {
428
529
  content: [
429
530
  {
430
531
  type: "text",
431
532
  text: 'Available skills. Fetch one with skills({ name: "<name>" }).\n\n' +
432
- AVAILABLE_SKILLS.map((skill) => `- \`${skill.name}\` — ${skill.description}`).join("\n"),
533
+ listSkills(connectors)
534
+ .map((skill) => `- \`${skill.name}\` — ${skill.description}`)
535
+ .join("\n"),
433
536
  },
434
537
  ],
435
538
  };
436
539
  }
437
- const skill = AVAILABLE_SKILLS.find((item) => item.name === args.name);
438
- if (!skill) {
439
- return errorResult(`Unknown skill "${args.name}". Available: ${AVAILABLE_SKILLS.map((item) => item.name).join(", ")}.`);
440
- }
540
+ const skill = resolveSkill(args.name, connectors);
541
+ if (!skill.found)
542
+ return errorResult(skill.message);
441
543
  return { content: [{ type: "text", text: skill.content }] };
442
544
  },
443
545
  async listConnectors(args = {}) {
444
546
  const probe = args.probe ?? true;
445
547
  const out = await Promise.all(registry.listConnectors().map(async (c) => {
446
- const checkedAt = new Date().toISOString();
447
548
  const statusStarted = Date.now();
448
549
  const observed = registry.healthFor(c.id);
550
+ const verdict = await registry.credentialHealthFor(c.id);
449
551
  let status;
450
552
  if (probe) {
451
553
  try {
@@ -458,17 +560,73 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
458
560
  status = { state: "error", message: msg(err) };
459
561
  }
460
562
  }
563
+ else if (verdict &&
564
+ // Deployment-wide, deliberately, like `hasObservedSuccess` beside
565
+ // it: a sibling toolkit's successful call proves the shared
566
+ // credential works, and a verdict retired for one view but not
567
+ // another would make the same connector read differently per scope
568
+ // for a reason that has nothing to do with scope.
569
+ credentialVerdictApplies(verdict, registry.observedSuccessAt(c.id))) {
570
+ // The proactive layer (issue #24): a liveness check already found
571
+ // the stored credential dead, so say so on the cheap path instead of
572
+ // waiting for an agent's real call to discover it. Only while it is
573
+ // the freshest evidence — a successful call since then retires it.
574
+ status = {
575
+ state: verdict.state,
576
+ ...(verdict.message ? { message: verdict.message } : {}),
577
+ ...(verdict.authorizationUrl
578
+ ? { authorizationUrl: verdict.authorizationUrl }
579
+ : {}),
580
+ };
581
+ }
461
582
  else {
583
+ // "error" comes from THIS view's own observations — a sibling
584
+ // toolkit's failure is not this session's experience — while
585
+ // ok/unknown may lean on the deployment-wide success signal, since
586
+ // "the connector answers at all" is a fact about the connector.
587
+ // Unscoped, the two are the same log, so this is unchanged there.
588
+ const derived = observed?.consecutiveFailures && observed.consecutiveFailures > 0
589
+ ? "error"
590
+ : registry.hasObservedSuccess(c.id) || c.kind === "api"
591
+ ? "ok"
592
+ : "unknown";
462
593
  status = {
463
- state: observed?.consecutiveFailures &&
464
- observed.consecutiveFailures > 0
465
- ? "error"
466
- : observed?.lastSuccessAt || c.kind === "api"
467
- ? "ok"
468
- : "unknown",
594
+ // A successful liveness check upgrades "unknown" — nothing has
595
+ // been called yet, but the credential was verified, which is how
596
+ // re-authorization shows up here as ok rather than as an absence
597
+ // of evidence. It never DOWNgrades an observed failure: a real
598
+ // call that failed is stronger evidence than a background check.
599
+ state: derived === "unknown" && verdict?.state === "ok"
600
+ ? "ok"
601
+ : derived,
469
602
  ...(observed?.lastError ? { message: observed.lastError } : {}),
470
603
  };
471
604
  }
605
+ // Stamped where the observation actually happened — after the status
606
+ // probe, not before it. A 30-second probe stamped at its start would
607
+ // report a verdict older than it is, and would lose the race against a
608
+ // real call that succeeded WHILE it ran (that success must retire the
609
+ // verdict, and only an honest timestamp says so).
610
+ const checkedAt = new Date().toISOString();
611
+ // A live status probe IS a liveness observation of the stored
612
+ // credential, so it updates the same verdict a background check
613
+ // writes: the cached read afterwards agrees with what the operator
614
+ // just saw, and they are not swept again moments later. Recorded from
615
+ // the STATUS phase only, and only when the connector actually answered
616
+ // — a catalog refresh below is not a credential check (the sweep never
617
+ // fetches one), it is already counted in the health log, and letting
618
+ // its failure land here would spend the freshness budget on it. The
619
+ // registry ignores this for connectors storing no credential of ours.
620
+ if (probe && (status.state === "ok" || status.state === "auth_required")) {
621
+ await registry.recordCredentialHealth(c.id, {
622
+ state: status.state,
623
+ checkedAt,
624
+ ...(status.message ? { message: status.message } : {}),
625
+ ...("authorizationUrl" in status && status.authorizationUrl
626
+ ? { authorizationUrl: status.authorizationUrl }
627
+ : {}),
628
+ });
629
+ }
472
630
  let tools = registry.peekTools(c.id);
473
631
  // An auth_required status may have just started OAuth. A second
474
632
  // listTools probe would overwrite its state/verifier while returning
@@ -485,6 +643,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
485
643
  }
486
644
  const latencyMs = Date.now() - statusStarted;
487
645
  const latestObserved = registry.healthFor(c.id);
646
+ const credentialCheck = probe
647
+ ? await registry.credentialHealthFor(c.id)
648
+ : verdict;
488
649
  return {
489
650
  id: c.id,
490
651
  ...(c.title ? { title: c.title } : {}),
@@ -495,6 +656,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
495
656
  latencyMs,
496
657
  probe,
497
658
  ...(latestObserved ?? observed ?? {}),
659
+ ...(credentialCheck ? { credentialCheck } : {}),
498
660
  ...("authorizationUrl" in status && status.authorizationUrl
499
661
  ? { authorizationUrl: status.authorizationUrl }
500
662
  : {}),
@@ -521,6 +683,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
521
683
  connectorId: c.id,
522
684
  connectorTitle: c.title,
523
685
  connectorDescription: c.description,
686
+ ...(connectorGuide(c)
687
+ ? { connectorGuideSkill: connectorSkillName(c.id) }
688
+ : {}),
524
689
  tool: ranked.tool,
525
690
  score: ranked.score,
526
691
  order: orderBase + ranked.order,
@@ -540,6 +705,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
540
705
  id: match.connectorId,
541
706
  ...(match.connectorTitle ? { title: match.connectorTitle } : {}),
542
707
  description: match.connectorDescription,
708
+ ...(match.connectorGuideSkill
709
+ ? { guide: match.connectorGuideSkill }
710
+ : {}),
543
711
  tools: [],
544
712
  };
545
713
  byConnector.set(match.connectorId, group);
@@ -621,6 +789,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
621
789
  address,
622
790
  name: tool.name,
623
791
  description: summarizeDescription(tool.description, args.fullDescriptions === true),
792
+ ...(connectorGuide(resolved.connector)
793
+ ? { guide: connectorSkillName(resolved.connector.id) }
794
+ : {}),
624
795
  inputSchema: format === "json" ? schema : compactSchema(schema),
625
796
  ...(tool.outputSchema
626
797
  ? {
@@ -641,6 +812,20 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
641
812
  return (await runCall(args, "call_destructive_tool", { allowDestructive: true })).toolResult;
642
813
  },
643
814
  async getResult(args) {
815
+ // Client-supplied page size and offset: normal input-validation errors,
816
+ // not clamps. Callers arriving over MCP are rejected earlier by the
817
+ // registered zod schema and never reach these branches, so they exist for
818
+ // in-process callers of createMetaTools — which have no schema in front
819
+ // of them — and to keep the rules true of the handler on its own terms.
820
+ if (args.maxBytes !== undefined &&
821
+ !isValidMaxResultBytes(args.maxBytes)) {
822
+ return errorResult(`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
823
+ `>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`);
824
+ }
825
+ if (args.offset !== undefined && !isValidResultOffset(args.offset)) {
826
+ return errorResult(`Invalid offset ${args.offset}: must be a whole number of bytes ` +
827
+ `>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
828
+ }
644
829
  const results = registry.resultsStorage();
645
830
  const stored = await results.get(`result:${args.id}`);
646
831
  if (stored === null || stored === undefined) {
@@ -648,8 +833,17 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
648
833
  }
649
834
  const bytes = enc.encode(stored);
650
835
  const total = bytes.length;
651
- const offset = Math.max(0, Math.trunc(args.offset ?? 0));
652
- const maxBytes = args.maxBytes ?? cap;
836
+ // Validated above, so no coercion is needed here — only alignment. A
837
+ // client that computes its own offsets can land inside a multi-byte
838
+ // character, which would decode as U+FFFD; the offset actually served is
839
+ // the boundary at or before it, and it is what the response reports back
840
+ // as `offset` (issue #38).
841
+ const offset = alignStartToCharBoundary(bytes, args.offset ?? 0);
842
+ // Page size only: a stashed result carries no connector identity, so
843
+ // get_result keeps the deployment-wide default when none is requested.
844
+ // Both sides are validated by now — the argument above, `globalCap` at
845
+ // intake — so `offset + maxBytes` always reaches past `offset`.
846
+ const maxBytes = args.maxBytes ?? globalCap;
653
847
  // Align the slice end to a codepoint boundary so a multi-byte char is
654
848
  // never split across pages (which would emit U+FFFD on both sides).
655
849
  // `nextOffset` is this aligned end, so it is a valid boundary for the
@@ -736,6 +930,17 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
736
930
  const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
737
931
  try {
738
932
  const status = await connector.startAuth(ctx, { force: args.force });
933
+ // startAuth just spoke to the downstream about this exact credential, so
934
+ // its answer replaces any older liveness verdict — including the stale
935
+ // `auth_required` that sent the agent here, once it reports ok.
936
+ await registry.recordCredentialHealth(connector.id, {
937
+ state: status.state,
938
+ checkedAt: new Date().toISOString(),
939
+ ...(status.message ? { message: status.message } : {}),
940
+ ...(status.authorizationUrl
941
+ ? { authorizationUrl: status.authorizationUrl }
942
+ : {}),
943
+ });
739
944
  if (status.state === "auth_required" && !status.authorizationUrl) {
740
945
  // auth_required with nothing to open is a dead end for the operator.
741
946
  return errorResult(`Connector "${connector.id}": authorization required but no URL is available — retry authorize_connector.`);
@@ -768,10 +973,32 @@ const SEARCH_DESC = 'Start here when a tool address is unknown. Exact/name match
768
973
  const DESCRIBE_DESC = 'Inspect known tool addresses when search_tools did not include a sufficient schema. Returns descriptions, input/output schemas, and behavior annotations; format "compact" is the default.';
769
974
  const CALL_DESC = 'Use for one tool explicitly annotated readOnlyHint: true. For 2–10 independent read-only calls use batch_call; for dependent steps or data reduction use execute_code when available. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths, resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
770
975
  const CALL_DESTRUCTIVE_DESC = "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.";
771
- const GET_RESULT_DESC = "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.";
976
+ const GET_RESULT_DESC = "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.";
772
977
  const BATCH_DESC = "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.";
773
978
  const AUTHORIZE_DESC = "Use after a connector reports auth_required. Starts downstream OAuth and returns an authorizationUrl for the operator to open. force=true wipes stored credentials first and restarts consent.";
774
979
  const SKILLS_DESC = '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.';
980
+ /**
981
+ * Sentences appended to a meta-tool description only when this connection
982
+ * actually has connector guides. Tool descriptions are always-loaded context,
983
+ * so a deployment with no guides gets every base description unchanged rather
984
+ * than paying for text about a feature it does not use.
985
+ *
986
+ * Registration is per connection and reads the connection's own registry view,
987
+ * so under a toolkit these sentences reflect the SCOPED connector set: a scoped
988
+ * session whose connectors carry no guides sees the base descriptions, and
989
+ * never learns from a tool description that guides exist out of scope.
990
+ */
991
+ const GUIDE_NOTES = {
992
+ skills: ' 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.',
993
+ search: " A connector group carrying `guide` has a usage guide; fetch it with skills({ name: <guide> }).",
994
+ describe: " An entry carrying `guide` belongs to a connector with a usage guide; fetch it with skills({ name: <guide> }).",
995
+ };
996
+ /** `base`, plus its guide note when any VISIBLE connector carries a guide. */
997
+ function describedFor(registry, base, note) {
998
+ return hasConnectorGuides(registry.listConnectors())
999
+ ? base + GUIDE_NOTES[note]
1000
+ : base;
1001
+ }
775
1002
  /**
776
1003
  * Connecta refuses downstream tools that are not explicitly annotated
777
1004
  * read-only, so its own meta-tools must carry the same hints — otherwise a
@@ -793,13 +1020,12 @@ const READ_ONLY_LOCAL = {
793
1020
  /** Register the nine meta-tools onto an McpServer instance. */
794
1021
  export function registerMetaTools(server, registry, ctx) {
795
1022
  const mt = createMetaTools(registry, ctx.baseUrl, {
796
- maxResultBytes: ctx.maxResultBytes,
797
1023
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
798
1024
  probeTimeoutMs: ctx.probeTimeoutMs,
799
1025
  activity: ctx.activity,
800
1026
  });
801
1027
  server.registerTool("skills", {
802
- description: SKILLS_DESC,
1028
+ description: describedFor(registry, SKILLS_DESC, "skills"),
803
1029
  inputSchema: { name: z.string().optional() },
804
1030
  annotations: {
805
1031
  readOnlyHint: true,
@@ -814,7 +1040,7 @@ export function registerMetaTools(server, registry, ctx) {
814
1040
  annotations: READ_ONLY_REMOTE,
815
1041
  }, async (args) => mt.listConnectors(args));
816
1042
  server.registerTool("search_tools", {
817
- description: SEARCH_DESC,
1043
+ description: describedFor(registry, SEARCH_DESC, "search"),
818
1044
  inputSchema: {
819
1045
  query: z.string().optional(),
820
1046
  connector: z.string().optional(),
@@ -826,7 +1052,7 @@ export function registerMetaTools(server, registry, ctx) {
826
1052
  annotations: READ_ONLY_REMOTE,
827
1053
  }, async (args) => mt.searchTools(args));
828
1054
  server.registerTool("describe_tools", {
829
- description: DESCRIBE_DESC,
1055
+ description: describedFor(registry, DESCRIBE_DESC, "describe"),
830
1056
  inputSchema: {
831
1057
  addresses: z.array(z.string()),
832
1058
  format: z.enum(["compact", "json"]).optional(),
@@ -884,8 +1110,12 @@ export function registerMetaTools(server, registry, ctx) {
884
1110
  description: GET_RESULT_DESC,
885
1111
  inputSchema: {
886
1112
  id: z.string(),
887
- offset: z.number().int().nonnegative().optional(),
888
- maxBytes: z.number().int().positive().optional(),
1113
+ // Both bounds are the shared rules (isValidResultOffset,
1114
+ // isValidMaxResultBytes) expressed for the wire: spelling them against
1115
+ // the same constants keeps the schema from drifting away from the
1116
+ // in-handler checks if either floor ever moves.
1117
+ offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
1118
+ maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
889
1119
  },
890
1120
  annotations: READ_ONLY_LOCAL,
891
1121
  }, async (args) => mt.getResult(args));