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