@zackbart/connecta 0.5.0 → 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.
- package/CHANGELOG.md +358 -0
- package/README.md +53 -12
- 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 +26 -1
- 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 +212 -0
- package/dist/credential-health.d.ts.map +1 -0
- package/dist/credential-health.js +535 -0
- package/dist/credential-health.js.map +1 -0
- 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 +96 -13
- 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 +292 -37
- 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 +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 +70 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +35 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +87 -3
- 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 +202 -5
- package/src/connectors/remote-mcp.ts +9 -0
- package/src/credential-health.ts +736 -0
- package/src/execute.ts +28 -4
- package/src/index.ts +176 -20
- package/src/meta-tools.ts +286 -109
- package/src/registry.ts +125 -1
- package/src/server.ts +349 -34
- package/src/skills.ts +1 -1
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +241 -6
- package/src/types.ts +76 -1
- package/src/ui.ts +98 -3
- package/src/version.ts +1 -1
package/dist/meta-tools.js
CHANGED
|
@@ -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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
*
|
|
186
|
-
*
|
|
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
|
|
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
|
-
|
|
197
|
-
const notice = JSON.stringify({
|
|
208
|
+
return {
|
|
198
209
|
truncated: true,
|
|
199
210
|
resultId: id,
|
|
200
|
-
totalBytes
|
|
211
|
+
totalBytes,
|
|
201
212
|
hint: "use get_result {id, offset} to page, or re-call with fields to select less",
|
|
202
|
-
}
|
|
203
|
-
|
|
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 =
|
|
243
|
+
const text = serializeResultText(value);
|
|
208
244
|
const bytes = enc.encode(text);
|
|
209
245
|
if (bytes.length <= cap)
|
|
210
246
|
return value;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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:
|
|
679
|
-
//
|
|
680
|
-
// registered zod schema and never reach
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
958
|
-
//
|
|
959
|
-
// the
|
|
960
|
-
// in-handler
|
|
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,
|