@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
@@ -38,6 +38,36 @@ export declare const MAX_RETRY_BACKOFF_MS = 10000;
38
38
  * `runCall`. Exported for direct testing.
39
39
  */
40
40
  export declare function retryBackoffMs(attempt: number, retryAfterMs: number | undefined): number | undefined;
41
+ /** Smallest accepted `get_result` byte offset. */
42
+ export declare const MIN_RESULT_OFFSET = 0;
43
+ /**
44
+ * The one definition of a usable `get_result` offset: a whole number of bytes
45
+ * at or past {@link MIN_RESULT_OFFSET}. Shared by the registered zod schema and
46
+ * the handler's own check, the way `isValidMaxResultBytes` is shared across the
47
+ * cap's intake points (issue #32) — so a value valid at the wire is valid in
48
+ * process, and the two cannot drift.
49
+ *
50
+ * Everything else is rejected rather than coerced, because coercion is how an
51
+ * out-of-domain offset used to void a result silently: `Math.max(0, NaN)` is
52
+ * `NaN`, which slices to nothing, serializes as `"offset": null`, and reports
53
+ * no `nextOffset` — a caller sees a successful, empty result instead of an
54
+ * error. An offset past the end of the payload stays legal: it is a whole
55
+ * number of bytes, and it answers with an empty final page.
56
+ */
57
+ export declare function isValidResultOffset(value: number): boolean;
58
+ /**
59
+ * Move a byte `offset` back to the nearest UTF-8 codepoint boundary in
60
+ * `[0, offset]`, so decoding from it never starts mid-character (which emits
61
+ * U+FFFD for the severed tail).
62
+ *
63
+ * Backwards, never forwards: re-serving a few bytes the caller already has is
64
+ * recoverable, silently skipping the rest of a character is not. Offsets the
65
+ * server itself produced (`nextOffset`) are already boundaries and come back
66
+ * unchanged, so this only moves an offset a client computed on its own
67
+ * (issue #38). An offset at or past `bytes.length` is left alone — there is no
68
+ * character there to split.
69
+ */
70
+ export declare function alignStartToCharBoundary(bytes: Uint8Array, offset: number): number;
41
71
  /**
42
72
  * Move a byte `end` back to the nearest UTF-8 codepoint boundary in
43
73
  * `(offset, total]`, so decoding `bytes[offset, end)` never splits a codepoint
@@ -54,6 +84,22 @@ export declare function retryBackoffMs(attempt: number, retryAfterMs: number | u
54
84
  * ever does. Exported for direct testing of that invariant.
55
85
  */
56
86
  export declare function alignEndToCharBoundary(bytes: Uint8Array, offset: number, end: number, total: number): number;
87
+ /**
88
+ * The one serialization every result guard measures, stashes, and pages: JSON
89
+ * text for whatever JSON can represent, and `String(value)` for the returns
90
+ * JSON renders as `undefined` — a handler that returns nothing, a function, or
91
+ * a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
92
+ * returning `undefined` for those, which is how a handler returning `undefined`
93
+ * reached clients as a `{"type":"text"}` block carrying no `text` at all: the
94
+ * size guard measured `enc.encode(undefined)` — the empty string, per the
95
+ * WebIDL default — and emitted the non-string unchanged (issue #42). `null`
96
+ * needs no special case; JSON renders it as `"null"`.
97
+ *
98
+ * Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
99
+ * the three give one answer to the same question. A value JSON cannot serialize
100
+ * at all (a BigInt) still throws, as before, and is reported as a failure.
101
+ */
102
+ export declare function serializeResultText(value: unknown): string;
57
103
  export interface SearchArgs {
58
104
  query?: string;
59
105
  connector?: string;
@@ -85,6 +131,10 @@ export interface CallArgs {
85
131
  }
86
132
  export interface GetResultArgs {
87
133
  id: string;
134
+ /**
135
+ * Byte offset to page from; a whole number >= 0, aligned back to the nearest
136
+ * character boundary and reported as the response's `offset`. Defaults to 0.
137
+ */
88
138
  offset?: number;
89
139
  /** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
90
140
  maxBytes?: number;
@@ -114,14 +164,16 @@ export interface SkillArgs {
114
164
  }
115
165
  /**
116
166
  * The nine meta-tool handlers over a registry. Exported for direct testing;
117
- * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
118
- * overrides the registry's default result-size cap (a connector's own
119
- * `maxResultBytes` overrides it in turn); `opts.defaultToolTimeoutMs`
167
+ * registerMetaTools() wires them onto an McpServer. `opts.defaultToolTimeoutMs`
120
168
  * supplies a deadline for calls that don't carry one. (execute_code, the
121
169
  * optional tenth tool, is registered separately by registerExecuteTool.)
170
+ *
171
+ * The deployment-wide result-size cap is read off the registry view rather than
172
+ * passed in: `ConnectaConfig.maxResultBytes` and the per-connector override are
173
+ * the only places a cap is set, so there is one answer to where a deployment
174
+ * sets it (issue #44).
122
175
  */
123
176
  export declare function createMetaTools(registry: RegistryView, baseUrl: string, opts?: {
124
- maxResultBytes?: number;
125
177
  /** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
126
178
  defaultToolTimeoutMs?: number;
127
179
  /** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
@@ -141,7 +193,6 @@ export declare function createMetaTools(registry: RegistryView, baseUrl: string,
141
193
  /** Register the nine meta-tools onto an McpServer instance. */
142
194
  export declare function registerMetaTools(server: McpServer, registry: RegistryView, ctx: {
143
195
  baseUrl: string;
144
- maxResultBytes?: number;
145
196
  defaultToolTimeoutMs?: number;
146
197
  probeTimeoutMs?: number;
147
198
  activity?: ActivityRequestContext;
@@ -1 +1 @@
1
- {"version":3,"file":"meta-tools.d.ts","sourceRoot":"","sources":["../src/meta-tools.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"meta-tools.d.ts","sourceRoot":"","sources":["../src/meta-tools.ts"],"names":[],"mappings":"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"}
@@ -5,6 +5,8 @@ import { unwrapMcpResult } from "./mcp-result.js";
5
5
  import { classifyCallError, ConnectorCallError, messageLooksRetryable, } from "./errors.js";
6
6
  import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
7
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";
8
10
  const RESULT_TTL_SECONDS = 900;
9
11
  export function jsonResult(obj) {
10
12
  return {
@@ -33,40 +35,6 @@ const dec = new TextDecoder();
33
35
  * decides when to re-issue.
34
36
  */
35
37
  export const MAX_RETRY_BACKOFF_MS = 10_000;
36
- /** A finite, positive integer number of milliseconds, or undefined. */
37
- function normalizeTimeoutMs(value) {
38
- if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
39
- return undefined;
40
- }
41
- return Math.max(1, Math.trunc(value));
42
- }
43
- /**
44
- * Generous default bound for a single downstream probe/catalog call in the
45
- * list/search/describe fan-out. High enough to trip only on a pathological
46
- * hang, not a realistically slow probe.
47
- */
48
- const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
49
- /**
50
- * Reject `promise` after `ms` if it has not settled, so one hung downstream
51
- * cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
52
- * the registry probe methods take no AbortSignal, so the underlying fetch is
53
- * NOT cancelled and keeps running in the background. Real cancellation
54
- * (AbortSignal plumbed through the registry) is a deferred follow-up.
55
- */
56
- function withTimeout(promise, ms, label) {
57
- return new Promise((resolve, reject) => {
58
- const timer = setTimeout(() => {
59
- reject(new Error(`${label} timed out after ${ms}ms`));
60
- }, ms);
61
- promise.then((value) => {
62
- clearTimeout(timer);
63
- resolve(value);
64
- }, (err) => {
65
- clearTimeout(timer);
66
- reject(err);
67
- });
68
- });
69
- }
70
38
  /**
71
39
  * How long to wait before the next attempt, or `undefined` for "don't retry".
72
40
  *
@@ -95,6 +63,43 @@ function errorDetails(code, message) {
95
63
  function isContinuationByte(b) {
96
64
  return (b & 0xc0) === 0x80;
97
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
+ }
98
103
  /**
99
104
  * Move a byte `end` back to the nearest UTF-8 codepoint boundary in
100
105
  * `(offset, total]`, so decoding `bytes[offset, end)` never splits a codepoint
@@ -174,59 +179,128 @@ function applyFieldsToContent(content, fields) {
174
179
  });
175
180
  }
176
181
  // --- result-size guard + get_result (feature 1) ---------------------------
177
- function contentBytes(content) {
178
- let n = 0;
179
- for (const b of content)
180
- if (b.type === "text")
181
- n += enc.encode(b.text).length;
182
- 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;
183
200
  }
184
201
  /**
185
- * Return `text` as a single content block; if it exceeds `cap` bytes, stash the
186
- * full text under `result:<uuid>` (ttl 900s) and return the first `cap` bytes
187
- * 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.
188
204
  */
189
- async function guardText(text, results, cap) {
190
- const bytes = enc.encode(text);
191
- if (bytes.length <= cap) {
192
- return { content: [{ type: "text", text }] };
193
- }
205
+ async function stashResult(text, results, totalBytes) {
194
206
  const id = crypto.randomUUID();
195
207
  await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
196
- const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
197
- const notice = JSON.stringify({
208
+ return {
198
209
  truncated: true,
199
210
  resultId: id,
200
- totalBytes: bytes.length,
211
+ totalBytes,
201
212
  hint: "use get_result {id, offset} to page, or re-call with fields to select less",
202
- });
203
- 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);
204
240
  }
205
241
  /** Store an oversized JSON value and replace it with a page handle. */
206
242
  async function guardValue(value, results, cap) {
207
- const text = JSON.stringify(value, null, 2) ?? String(value);
243
+ const text = serializeResultText(value);
208
244
  const bytes = enc.encode(text);
209
245
  if (bytes.length <= cap)
210
246
  return value;
211
- const id = crypto.randomUUID();
212
- await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
213
- return {
214
- truncated: true,
215
- resultId: id,
216
- totalBytes: bytes.length,
217
- hint: "use get_result {id, offset} to page, or re-call with fields to select less",
218
- };
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) }] };
219
289
  }
220
290
  /**
221
291
  * The nine meta-tool handlers over a registry. Exported for direct testing;
222
- * registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
223
- * overrides the registry's default result-size cap (a connector's own
224
- * `maxResultBytes` overrides it in turn); `opts.defaultToolTimeoutMs`
292
+ * registerMetaTools() wires them onto an McpServer. `opts.defaultToolTimeoutMs`
225
293
  * supplies a deadline for calls that don't carry one. (execute_code, the
226
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).
227
300
  */
228
301
  export function createMetaTools(registry, baseUrl, opts = {}) {
229
- const globalCap = resolveMaxResultBytes(opts.maxResultBytes, registry.maxResultBytes);
302
+ // Already normalized and warned about at registry construction.
303
+ const globalCap = registry.maxResultBytes;
230
304
  const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
231
305
  const probeTimeoutMs = normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
232
306
  // createMetaTools() is called once per inbound MCP request. Sharing this
@@ -306,6 +380,20 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
306
380
  }
307
381
  catch (err) {
308
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);
309
397
  // classifyCallError so a typed auth_required thrown while listing tools
310
398
  // (e.g. a revoked downstream OAuth grant) keeps its code.
311
399
  return failed(classifyCallError(err, "catalog_lookup_failed"));
@@ -406,13 +494,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
406
494
  let content = mr?.content ?? [];
407
495
  if (fields)
408
496
  content = applyFieldsToContent(content, fields);
409
- let toolResult;
410
- if (contentBytes(content) > cap) {
411
- toolResult = await guardText(JSON.stringify(content, null, 2), results, cap);
412
- }
413
- else {
414
- toolResult = { content };
415
- }
497
+ const toolResult = await guardContent(content, results, cap);
416
498
  resultProcessingMs += Date.now() - processingStarted;
417
499
  record("success");
418
500
  return {
@@ -423,7 +505,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
423
505
  };
424
506
  }
425
507
  const value = fields ? applyFields(result, fields) : result;
426
- const toolResult = await guardText(JSON.stringify(value, null, 2), results, cap);
508
+ const toolResult = await guardText(serializeResultText(value), results, cap);
427
509
  resultProcessingMs += Date.now() - processingStarted;
428
510
  record("success");
429
511
  return {
@@ -463,9 +545,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
463
545
  async listConnectors(args = {}) {
464
546
  const probe = args.probe ?? true;
465
547
  const out = await Promise.all(registry.listConnectors().map(async (c) => {
466
- const checkedAt = new Date().toISOString();
467
548
  const statusStarted = Date.now();
468
549
  const observed = registry.healthFor(c.id);
550
+ const verdict = await registry.credentialHealthFor(c.id);
469
551
  let status;
470
552
  if (probe) {
471
553
  try {
@@ -478,22 +560,73 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
478
560
  status = { state: "error", message: msg(err) };
479
561
  }
480
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
+ }
481
582
  else {
482
583
  // "error" comes from THIS view's own observations — a sibling
483
584
  // toolkit's failure is not this session's experience — while
484
585
  // ok/unknown may lean on the deployment-wide success signal, since
485
586
  // "the connector answers at all" is a fact about the connector.
486
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";
487
593
  status = {
488
- state: observed?.consecutiveFailures &&
489
- observed.consecutiveFailures > 0
490
- ? "error"
491
- : registry.hasObservedSuccess(c.id) || c.kind === "api"
492
- ? "ok"
493
- : "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,
494
602
  ...(observed?.lastError ? { message: observed.lastError } : {}),
495
603
  };
496
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
+ }
497
630
  let tools = registry.peekTools(c.id);
498
631
  // An auth_required status may have just started OAuth. A second
499
632
  // listTools probe would overwrite its state/verifier while returning
@@ -510,6 +643,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
510
643
  }
511
644
  const latencyMs = Date.now() - statusStarted;
512
645
  const latestObserved = registry.healthFor(c.id);
646
+ const credentialCheck = probe
647
+ ? await registry.credentialHealthFor(c.id)
648
+ : verdict;
513
649
  return {
514
650
  id: c.id,
515
651
  ...(c.title ? { title: c.title } : {}),
@@ -520,6 +656,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
520
656
  latencyMs,
521
657
  probe,
522
658
  ...(latestObserved ?? observed ?? {}),
659
+ ...(credentialCheck ? { credentialCheck } : {}),
523
660
  ...("authorizationUrl" in status && status.authorizationUrl
524
661
  ? { authorizationUrl: status.authorizationUrl }
525
662
  : {}),
@@ -675,16 +812,20 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
675
812
  return (await runCall(args, "call_destructive_tool", { allowDestructive: true })).toolResult;
676
813
  },
677
814
  async getResult(args) {
678
- // Client-supplied page size: a normal input-validation error, not a
679
- // clamp. Callers arriving over MCP are rejected earlier by the
680
- // registered zod schema and never reach this branch, so it exists for
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
681
818
  // in-process callers of createMetaTools — which have no schema in front
682
- // of them — and to keep the rule true of the handler on its own terms.
819
+ // of them — and to keep the rules true of the handler on its own terms.
683
820
  if (args.maxBytes !== undefined &&
684
821
  !isValidMaxResultBytes(args.maxBytes)) {
685
822
  return errorResult(`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
686
823
  `>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`);
687
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
+ }
688
829
  const results = registry.resultsStorage();
689
830
  const stored = await results.get(`result:${args.id}`);
690
831
  if (stored === null || stored === undefined) {
@@ -692,7 +833,12 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
692
833
  }
693
834
  const bytes = enc.encode(stored);
694
835
  const total = bytes.length;
695
- const offset = Math.max(0, Math.trunc(args.offset ?? 0));
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);
696
842
  // Page size only: a stashed result carries no connector identity, so
697
843
  // get_result keeps the deployment-wide default when none is requested.
698
844
  // Both sides are validated by now — the argument above, `globalCap` at
@@ -784,6 +930,17 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
784
930
  const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
785
931
  try {
786
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
+ });
787
944
  if (status.state === "auth_required" && !status.authorizationUrl) {
788
945
  // auth_required with nothing to open is a dead end for the operator.
789
946
  return errorResult(`Connector "${connector.id}": authorization required but no URL is available — retry authorize_connector.`);
@@ -816,7 +973,7 @@ const SEARCH_DESC = 'Start here when a tool address is unknown. Exact/name match
816
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.';
817
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.';
818
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.";
819
- 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). 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.";
820
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.";
821
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.";
822
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.';
@@ -863,7 +1020,6 @@ const READ_ONLY_LOCAL = {
863
1020
  /** Register the nine meta-tools onto an McpServer instance. */
864
1021
  export function registerMetaTools(server, registry, ctx) {
865
1022
  const mt = createMetaTools(registry, ctx.baseUrl, {
866
- maxResultBytes: ctx.maxResultBytes,
867
1023
  defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
868
1024
  probeTimeoutMs: ctx.probeTimeoutMs,
869
1025
  activity: ctx.activity,
@@ -954,10 +1110,11 @@ export function registerMetaTools(server, registry, ctx) {
954
1110
  description: GET_RESULT_DESC,
955
1111
  inputSchema: {
956
1112
  id: z.string(),
957
- offset: z.number().int().nonnegative().optional(),
958
- // Same rule as isValidMaxResultBytes, expressed for the wire: sharing
959
- // the floor constant keeps the schema from drifting away from the
960
- // in-handler check if MIN_MAX_RESULT_BYTES ever moves.
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(),
961
1118
  maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
962
1119
  },
963
1120
  annotations: READ_ONLY_LOCAL,