@zackbart/connecta 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +527 -0
- package/README.md +83 -7
- package/SECURITY.md +10 -6
- package/dist/activity.d.ts +8 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +1 -0
- package/dist/activity.js.map +1 -1
- 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/api.d.ts +13 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +2 -0
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +13 -0
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +10 -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 +4 -4
- 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 +77 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -2
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +76 -7
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +328 -98
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +245 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +377 -27
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +7 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +342 -27
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +53 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +162 -2
- 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 +138 -0
- package/dist/toolkits.d.ts.map +1 -0
- package/dist/toolkits.js +319 -0
- package/dist/toolkits.js.map +1 -0
- package/dist/types.d.ts +90 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +63 -0
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +176 -11
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -2
- package/src/activity.ts +9 -0
- package/src/auth/bearer.ts +35 -1
- package/src/auth/clerk.ts +202 -5
- package/src/connectors/api.ts +15 -0
- package/src/connectors/remote-mcp.ts +24 -0
- package/src/credential-health.ts +736 -0
- package/src/execute.ts +32 -8
- package/src/index.ts +226 -2
- package/src/meta-tools.ts +397 -119
- package/src/registry.ts +540 -29
- package/src/server.ts +431 -25
- package/src/skills.ts +185 -2
- package/src/timeout.ts +49 -0
- package/src/toolkits.ts +450 -0
- package/src/types.ts +96 -2
- package/src/ui.ts +190 -11
- package/src/version.ts +1 -1
package/src/meta-tools.ts
CHANGED
|
@@ -13,8 +13,25 @@ import {
|
|
|
13
13
|
messageLooksRetryable,
|
|
14
14
|
type CallErrorDetails,
|
|
15
15
|
} from "./errors.js";
|
|
16
|
-
import
|
|
17
|
-
|
|
16
|
+
import {
|
|
17
|
+
isValidMaxResultBytes,
|
|
18
|
+
MIN_MAX_RESULT_BYTES,
|
|
19
|
+
resolveMaxResultBytes,
|
|
20
|
+
type RegistryView,
|
|
21
|
+
} from "./registry.js";
|
|
22
|
+
import {
|
|
23
|
+
connectorGuide,
|
|
24
|
+
connectorSkillName,
|
|
25
|
+
hasConnectorGuides,
|
|
26
|
+
listSkills,
|
|
27
|
+
resolveSkill,
|
|
28
|
+
} from "./skills.js";
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_PROBE_TIMEOUT_MS,
|
|
31
|
+
normalizeTimeoutMs,
|
|
32
|
+
withTimeout,
|
|
33
|
+
} from "./timeout.js";
|
|
34
|
+
import { credentialVerdictApplies } from "./credential-health.js";
|
|
18
35
|
import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
|
|
19
36
|
|
|
20
37
|
interface TextContent {
|
|
@@ -64,50 +81,6 @@ type ErrorDetails = CallErrorDetails;
|
|
|
64
81
|
*/
|
|
65
82
|
export const MAX_RETRY_BACKOFF_MS = 10_000;
|
|
66
83
|
|
|
67
|
-
/** A finite, positive integer number of milliseconds, or undefined. */
|
|
68
|
-
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
69
|
-
if (value === undefined || !Number.isFinite(value) || !(value > 0)) {
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
return Math.max(1, Math.trunc(value));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Generous default bound for a single downstream probe/catalog call in the
|
|
77
|
-
* list/search/describe fan-out. High enough to trip only on a pathological
|
|
78
|
-
* hang, not a realistically slow probe.
|
|
79
|
-
*/
|
|
80
|
-
const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Reject `promise` after `ms` if it has not settled, so one hung downstream
|
|
84
|
-
* cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
|
|
85
|
-
* the registry probe methods take no AbortSignal, so the underlying fetch is
|
|
86
|
-
* NOT cancelled and keeps running in the background. Real cancellation
|
|
87
|
-
* (AbortSignal plumbed through the registry) is a deferred follow-up.
|
|
88
|
-
*/
|
|
89
|
-
function withTimeout<T>(
|
|
90
|
-
promise: Promise<T>,
|
|
91
|
-
ms: number,
|
|
92
|
-
label: string,
|
|
93
|
-
): Promise<T> {
|
|
94
|
-
return new Promise<T>((resolve, reject) => {
|
|
95
|
-
const timer = setTimeout(() => {
|
|
96
|
-
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
97
|
-
}, ms);
|
|
98
|
-
promise.then(
|
|
99
|
-
(value) => {
|
|
100
|
-
clearTimeout(timer);
|
|
101
|
-
resolve(value);
|
|
102
|
-
},
|
|
103
|
-
(err) => {
|
|
104
|
-
clearTimeout(timer);
|
|
105
|
-
reject(err);
|
|
106
|
-
},
|
|
107
|
-
);
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
84
|
/**
|
|
112
85
|
* How long to wait before the next attempt, or `undefined` for "don't retry".
|
|
113
86
|
*
|
|
@@ -142,6 +115,48 @@ function isContinuationByte(b: number): boolean {
|
|
|
142
115
|
return (b & 0xc0) === 0x80;
|
|
143
116
|
}
|
|
144
117
|
|
|
118
|
+
/** Smallest accepted `get_result` byte offset. */
|
|
119
|
+
export const MIN_RESULT_OFFSET = 0;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The one definition of a usable `get_result` offset: a whole number of bytes
|
|
123
|
+
* at or past {@link MIN_RESULT_OFFSET}. Shared by the registered zod schema and
|
|
124
|
+
* the handler's own check, the way `isValidMaxResultBytes` is shared across the
|
|
125
|
+
* cap's intake points (issue #32) — so a value valid at the wire is valid in
|
|
126
|
+
* process, and the two cannot drift.
|
|
127
|
+
*
|
|
128
|
+
* Everything else is rejected rather than coerced, because coercion is how an
|
|
129
|
+
* out-of-domain offset used to void a result silently: `Math.max(0, NaN)` is
|
|
130
|
+
* `NaN`, which slices to nothing, serializes as `"offset": null`, and reports
|
|
131
|
+
* no `nextOffset` — a caller sees a successful, empty result instead of an
|
|
132
|
+
* error. An offset past the end of the payload stays legal: it is a whole
|
|
133
|
+
* number of bytes, and it answers with an empty final page.
|
|
134
|
+
*/
|
|
135
|
+
export function isValidResultOffset(value: number): boolean {
|
|
136
|
+
return Number.isInteger(value) && value >= MIN_RESULT_OFFSET;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Move a byte `offset` back to the nearest UTF-8 codepoint boundary in
|
|
141
|
+
* `[0, offset]`, so decoding from it never starts mid-character (which emits
|
|
142
|
+
* U+FFFD for the severed tail).
|
|
143
|
+
*
|
|
144
|
+
* Backwards, never forwards: re-serving a few bytes the caller already has is
|
|
145
|
+
* recoverable, silently skipping the rest of a character is not. Offsets the
|
|
146
|
+
* server itself produced (`nextOffset`) are already boundaries and come back
|
|
147
|
+
* unchanged, so this only moves an offset a client computed on its own
|
|
148
|
+
* (issue #38). An offset at or past `bytes.length` is left alone — there is no
|
|
149
|
+
* character there to split.
|
|
150
|
+
*/
|
|
151
|
+
export function alignStartToCharBoundary(
|
|
152
|
+
bytes: Uint8Array,
|
|
153
|
+
offset: number,
|
|
154
|
+
): number {
|
|
155
|
+
let o = offset;
|
|
156
|
+
while (o > 0 && isContinuationByte(bytes[o])) o--;
|
|
157
|
+
return o;
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
/**
|
|
146
161
|
* Move a byte `end` back to the nearest UTF-8 codepoint boundary in
|
|
147
162
|
* `(offset, total]`, so decoding `bytes[offset, end)` never splits a codepoint
|
|
@@ -150,19 +165,28 @@ function isContinuationByte(b: number): boolean {
|
|
|
150
165
|
* forward to the end of that codepoint instead so paging always advances.
|
|
151
166
|
* Assumes `offset` is itself a codepoint boundary (offsets are the prior
|
|
152
167
|
* `nextOffset`, which this function guarantees, and 0 is always a boundary).
|
|
168
|
+
*
|
|
169
|
+
* The return is always `> offset` while `offset < total`, whatever `end` is
|
|
170
|
+
* asked for. That is the belt-and-braces half of issue #32: cap validation
|
|
171
|
+
* keeps an empty window from arising in the first place, and this keeps an
|
|
172
|
+
* empty window from turning into a `nextOffset === offset` paging loop if one
|
|
173
|
+
* ever does. Exported for direct testing of that invariant.
|
|
153
174
|
*/
|
|
154
|
-
function alignEndToCharBoundary(
|
|
175
|
+
export function alignEndToCharBoundary(
|
|
155
176
|
bytes: Uint8Array,
|
|
156
177
|
offset: number,
|
|
157
178
|
end: number,
|
|
158
179
|
total: number,
|
|
159
180
|
): number {
|
|
160
181
|
if (end >= total) return total;
|
|
161
|
-
|
|
182
|
+
// A window that reaches no further than `offset` yields no bytes and no
|
|
183
|
+
// progress; widen it to one byte and let the codepoint walk below finish it.
|
|
184
|
+
const wanted = Math.max(end, offset + 1);
|
|
185
|
+
let e = wanted;
|
|
162
186
|
while (e > offset && isContinuationByte(bytes[e])) e--;
|
|
163
187
|
if (e === offset) {
|
|
164
188
|
// Window is narrower than the codepoint at `offset`; take the whole thing.
|
|
165
|
-
e =
|
|
189
|
+
e = wanted;
|
|
166
190
|
while (e < total && isContinuationByte(bytes[e])) e++;
|
|
167
191
|
}
|
|
168
192
|
return e;
|
|
@@ -220,39 +244,88 @@ function applyFieldsToContent(
|
|
|
220
244
|
|
|
221
245
|
// --- result-size guard + get_result (feature 1) ---------------------------
|
|
222
246
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
247
|
+
/**
|
|
248
|
+
* The one serialization every result guard measures, stashes, and pages: JSON
|
|
249
|
+
* text for whatever JSON can represent, and `String(value)` for the returns
|
|
250
|
+
* JSON renders as `undefined` — a handler that returns nothing, a function, or
|
|
251
|
+
* a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
|
|
252
|
+
* returning `undefined` for those, which is how a handler returning `undefined`
|
|
253
|
+
* reached clients as a `{"type":"text"}` block carrying no `text` at all: the
|
|
254
|
+
* size guard measured `enc.encode(undefined)` — the empty string, per the
|
|
255
|
+
* WebIDL default — and emitted the non-string unchanged (issue #42). `null`
|
|
256
|
+
* needs no special case; JSON renders it as `"null"`.
|
|
257
|
+
*
|
|
258
|
+
* Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
|
|
259
|
+
* the three give one answer to the same question. A value JSON cannot serialize
|
|
260
|
+
* at all (a BigInt) still throws, as before, and is reported as a failure.
|
|
261
|
+
*/
|
|
262
|
+
export function serializeResultText(value: unknown): string {
|
|
263
|
+
const serialized = JSON.stringify(value, null, 2);
|
|
264
|
+
return serialized === undefined ? String(value) : serialized;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
|
|
269
|
+
* truncation notice every over-cap path hands back.
|
|
270
|
+
*/
|
|
271
|
+
async function stashResult(
|
|
272
|
+
text: string,
|
|
273
|
+
results: KVStorage,
|
|
274
|
+
totalBytes: number,
|
|
275
|
+
): Promise<{
|
|
276
|
+
truncated: true;
|
|
277
|
+
resultId: string;
|
|
278
|
+
totalBytes: number;
|
|
279
|
+
hint: string;
|
|
280
|
+
}> {
|
|
281
|
+
const id = crypto.randomUUID();
|
|
282
|
+
await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
|
|
283
|
+
return {
|
|
284
|
+
truncated: true,
|
|
285
|
+
resultId: id,
|
|
286
|
+
totalBytes,
|
|
287
|
+
hint: "use get_result {id, offset} to page, or re-call with fields to select less",
|
|
288
|
+
};
|
|
228
289
|
}
|
|
229
290
|
|
|
230
291
|
/**
|
|
231
292
|
* Return `text` as a single content block; if it exceeds `cap` bytes, stash the
|
|
232
|
-
* full text
|
|
233
|
-
*
|
|
293
|
+
* full text and return the first `cap` bytes followed by a JSON truncation
|
|
294
|
+
* notice pointing at get_result. `bytes` is `text` already encoded, so a caller
|
|
295
|
+
* that had to measure it to make this decision doesn't encode it twice.
|
|
234
296
|
*/
|
|
235
|
-
async function
|
|
297
|
+
async function guardEncoded(
|
|
236
298
|
text: string,
|
|
299
|
+
bytes: Uint8Array,
|
|
237
300
|
results: KVStorage,
|
|
238
301
|
cap: number,
|
|
239
302
|
): Promise<ToolResult> {
|
|
240
|
-
const bytes = enc.encode(text);
|
|
241
303
|
if (bytes.length <= cap) {
|
|
242
304
|
return { content: [{ type: "text", text }] };
|
|
243
305
|
}
|
|
244
|
-
const
|
|
245
|
-
await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
|
|
306
|
+
const notice = await stashResult(text, results, bytes.length);
|
|
246
307
|
const head = dec.decode(
|
|
247
308
|
bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)),
|
|
248
309
|
);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
310
|
+
return {
|
|
311
|
+
content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** {@link guardEncoded} over a string that has not been measured yet. */
|
|
316
|
+
async function guardText(
|
|
317
|
+
text: string,
|
|
318
|
+
results: KVStorage,
|
|
319
|
+
cap: number,
|
|
320
|
+
): Promise<ToolResult> {
|
|
321
|
+
// `JSON.stringify`'s type says `string` where its behavior says `string |
|
|
322
|
+
// undefined`, so TypeScript alone does not keep a non-string out of here.
|
|
323
|
+
// Normalizing at the door means the size check below always measures exactly
|
|
324
|
+
// the text that is emitted, and no future caller can launder a non-string
|
|
325
|
+
// through it the way issue #42 describes.
|
|
326
|
+
const body: string =
|
|
327
|
+
typeof text === "string" ? text : serializeResultText(text);
|
|
328
|
+
return guardEncoded(body, enc.encode(body), results, cap);
|
|
256
329
|
}
|
|
257
330
|
|
|
258
331
|
/** Store an oversized JSON value and replace it with a page handle. */
|
|
@@ -261,17 +334,54 @@ async function guardValue(
|
|
|
261
334
|
results: KVStorage,
|
|
262
335
|
cap: number,
|
|
263
336
|
): Promise<unknown> {
|
|
264
|
-
const text =
|
|
337
|
+
const text = serializeResultText(value);
|
|
265
338
|
const bytes = enc.encode(text);
|
|
266
339
|
if (bytes.length <= cap) return value;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
340
|
+
return stashResult(text, results, bytes.length);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Bound a downstream MCP `content` array by `cap`, measuring the serialized
|
|
345
|
+
* envelope — the same string that gets stashed and paged, and the one that
|
|
346
|
+
* counts every block rather than only the text ones.
|
|
347
|
+
*
|
|
348
|
+
* Both halves matter (issue #43). Measuring only text blocks meant an oversized
|
|
349
|
+
* all-image result scored zero bytes and was returned inline unbounded, with no
|
|
350
|
+
* `resultId` to page from; and measuring one string while truncating another
|
|
351
|
+
* left `totalBytes` and the served head describing something the cap was never
|
|
352
|
+
* compared against.
|
|
353
|
+
*
|
|
354
|
+
* Over the cap, what a client gets depends on whether a prefix is usable. An
|
|
355
|
+
* all-text envelope keeps the historical head + notice — a JSON prefix is still
|
|
356
|
+
* readable. An envelope carrying non-text blocks is replaced by the notice
|
|
357
|
+
* alone: the head of a half-written base64 image is of no use to anyone, and
|
|
358
|
+
* cutting one leaves unparseable block structure behind. Either way the full
|
|
359
|
+
* envelope is stashed and pages through `get_result`.
|
|
360
|
+
*/
|
|
361
|
+
async function guardContent(
|
|
362
|
+
content: TextContent[],
|
|
363
|
+
results: KVStorage,
|
|
364
|
+
cap: number,
|
|
365
|
+
): Promise<ToolResult> {
|
|
366
|
+
let text: string;
|
|
367
|
+
try {
|
|
368
|
+
text = JSON.stringify(content, null, 2);
|
|
369
|
+
} catch {
|
|
370
|
+
// A block carrying a BigInt or a cycle cannot be serialized, so it cannot
|
|
371
|
+
// be measured, stashed, or paged either — there is nothing this guard could
|
|
372
|
+
// do with it. Pass it through as the old text-only measure did, rather than
|
|
373
|
+
// turning a call that used to succeed into result_processing_failed.
|
|
374
|
+
return { content };
|
|
375
|
+
}
|
|
376
|
+
const bytes = enc.encode(text);
|
|
377
|
+
// Under the cap the downstream blocks pass through untouched, non-text ones
|
|
378
|
+
// included, in their original order.
|
|
379
|
+
if (bytes.length <= cap) return { content };
|
|
380
|
+
if (content.every((b) => b.type === "text")) {
|
|
381
|
+
return guardEncoded(text, bytes, results, cap);
|
|
382
|
+
}
|
|
383
|
+
const notice = await stashResult(text, results, bytes.length);
|
|
384
|
+
return { content: [{ type: "text", text: JSON.stringify(notice) }] };
|
|
275
385
|
}
|
|
276
386
|
|
|
277
387
|
// --- compact schema rendering (feature 3a) --------------------------------
|
|
@@ -309,7 +419,12 @@ export interface CallArgs {
|
|
|
309
419
|
}
|
|
310
420
|
export interface GetResultArgs {
|
|
311
421
|
id: string;
|
|
422
|
+
/**
|
|
423
|
+
* Byte offset to page from; a whole number >= 0, aligned back to the nearest
|
|
424
|
+
* character boundary and reported as the response's `offset`. Defaults to 0.
|
|
425
|
+
*/
|
|
312
426
|
offset?: number;
|
|
427
|
+
/** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
|
|
313
428
|
maxBytes?: number;
|
|
314
429
|
}
|
|
315
430
|
export interface BatchCall {
|
|
@@ -338,16 +453,19 @@ export interface SkillArgs {
|
|
|
338
453
|
|
|
339
454
|
/**
|
|
340
455
|
* The nine meta-tool handlers over a registry. Exported for direct testing;
|
|
341
|
-
* registerMetaTools() wires them onto an McpServer. `opts.
|
|
342
|
-
* overrides the registry's default result-size cap; `opts.defaultToolTimeoutMs`
|
|
456
|
+
* registerMetaTools() wires them onto an McpServer. `opts.defaultToolTimeoutMs`
|
|
343
457
|
* supplies a deadline for calls that don't carry one. (execute_code, the
|
|
344
458
|
* optional tenth tool, is registered separately by registerExecuteTool.)
|
|
459
|
+
*
|
|
460
|
+
* The deployment-wide result-size cap is read off the registry view rather than
|
|
461
|
+
* passed in: `ConnectaConfig.maxResultBytes` and the per-connector override are
|
|
462
|
+
* the only places a cap is set, so there is one answer to where a deployment
|
|
463
|
+
* sets it (issue #44).
|
|
345
464
|
*/
|
|
346
465
|
export function createMetaTools(
|
|
347
|
-
registry:
|
|
466
|
+
registry: RegistryView,
|
|
348
467
|
baseUrl: string,
|
|
349
468
|
opts: {
|
|
350
|
-
maxResultBytes?: number;
|
|
351
469
|
/** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
|
|
352
470
|
defaultToolTimeoutMs?: number;
|
|
353
471
|
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
@@ -355,7 +473,8 @@ export function createMetaTools(
|
|
|
355
473
|
activity?: ActivityRequestContext;
|
|
356
474
|
} = {},
|
|
357
475
|
) {
|
|
358
|
-
|
|
476
|
+
// Already normalized and warned about at registry construction.
|
|
477
|
+
const globalCap = registry.maxResultBytes;
|
|
359
478
|
const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
|
|
360
479
|
const probeTimeoutMs =
|
|
361
480
|
normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
@@ -442,6 +561,16 @@ export function createMetaTools(
|
|
|
442
561
|
);
|
|
443
562
|
}
|
|
444
563
|
const results = registry.resultsStorage();
|
|
564
|
+
// Result-size cap for THIS call: the connector's own override wins, then
|
|
565
|
+
// the deployment-wide value, then the built-in default (already folded
|
|
566
|
+
// into `globalCap`). Resolved per call so one batch_call can mix a
|
|
567
|
+
// tight-capped connector with siblings on the global cap. An override the
|
|
568
|
+
// registry already warned about at startup is dropped here, so the
|
|
569
|
+
// connector simply inherits `globalCap`.
|
|
570
|
+
const cap = resolveMaxResultBytes(
|
|
571
|
+
resolved.connector.maxResultBytes,
|
|
572
|
+
globalCap,
|
|
573
|
+
);
|
|
445
574
|
const fields = call.fields && call.fields.length > 0 ? call.fields : null;
|
|
446
575
|
// An explicit per-call deadline always wins; the config default only fills
|
|
447
576
|
// the gap, and stays off entirely when the deployment sets none.
|
|
@@ -458,6 +587,20 @@ export function createMetaTools(
|
|
|
458
587
|
).find((tool) => tool.name === resolved.toolName);
|
|
459
588
|
} catch (err) {
|
|
460
589
|
catalogMs += Date.now() - catalogStarted;
|
|
590
|
+
// A connector whose catalog cannot be fetched is as unusable as one whose
|
|
591
|
+
// execution fails, so it feeds health accounting the same way the
|
|
592
|
+
// execution catch below does — otherwise a connector every call_tool
|
|
593
|
+
// fails against (a revoked downstream grant, say) still reads clean from
|
|
594
|
+
// the cheap `list_connectors({ probe: false })` signal.
|
|
595
|
+
//
|
|
596
|
+
// Recorded HERE rather than inside the registry's catalog fetch on
|
|
597
|
+
// purpose: `registry` is this connection's VIEW, so a toolkit-scoped
|
|
598
|
+
// session records into its own log as well as the deployment-wide one,
|
|
599
|
+
// which `Registry.refreshTools` could not reach. A cache hit that avoids
|
|
600
|
+
// a live listTools call therefore records nothing either way — it is not
|
|
601
|
+
// evidence of health, and success stays what it has always been: an
|
|
602
|
+
// actual downstream call that returned.
|
|
603
|
+
registry.recordFailure(resolved.connector.id, Date.now() - started, err);
|
|
461
604
|
// classifyCallError so a typed auth_required thrown while listing tools
|
|
462
605
|
// (e.g. a revoked downstream OAuth grant) keeps its code.
|
|
463
606
|
return failed(classifyCallError(err, "catalog_lookup_failed"));
|
|
@@ -590,16 +733,7 @@ export function createMetaTools(
|
|
|
590
733
|
if (resolved.connector.kind === "mcp") {
|
|
591
734
|
let content = mr?.content ?? [];
|
|
592
735
|
if (fields) content = applyFieldsToContent(content, fields);
|
|
593
|
-
|
|
594
|
-
if (contentBytes(content) > cap) {
|
|
595
|
-
toolResult = await guardText(
|
|
596
|
-
JSON.stringify(content, null, 2),
|
|
597
|
-
results,
|
|
598
|
-
cap,
|
|
599
|
-
);
|
|
600
|
-
} else {
|
|
601
|
-
toolResult = { content };
|
|
602
|
-
}
|
|
736
|
+
const toolResult = await guardContent(content, results, cap);
|
|
603
737
|
resultProcessingMs += Date.now() - processingStarted;
|
|
604
738
|
record("success");
|
|
605
739
|
return {
|
|
@@ -611,7 +745,7 @@ export function createMetaTools(
|
|
|
611
745
|
}
|
|
612
746
|
const value = fields ? applyFields(result, fields) : result;
|
|
613
747
|
const toolResult = await guardText(
|
|
614
|
-
|
|
748
|
+
serializeResultText(value),
|
|
615
749
|
results,
|
|
616
750
|
cap,
|
|
617
751
|
);
|
|
@@ -632,6 +766,7 @@ export function createMetaTools(
|
|
|
632
766
|
|
|
633
767
|
return {
|
|
634
768
|
async skills(args: SkillArgs = {}): Promise<ToolResult> {
|
|
769
|
+
const connectors = registry.listConnectors();
|
|
635
770
|
if (!args.name) {
|
|
636
771
|
return {
|
|
637
772
|
content: [
|
|
@@ -639,19 +774,15 @@ export function createMetaTools(
|
|
|
639
774
|
type: "text",
|
|
640
775
|
text:
|
|
641
776
|
'Available skills. Fetch one with skills({ name: "<name>" }).\n\n' +
|
|
642
|
-
|
|
643
|
-
(skill) => `- \`${skill.name}\` — ${skill.description}
|
|
644
|
-
|
|
777
|
+
listSkills(connectors)
|
|
778
|
+
.map((skill) => `- \`${skill.name}\` — ${skill.description}`)
|
|
779
|
+
.join("\n"),
|
|
645
780
|
},
|
|
646
781
|
],
|
|
647
782
|
};
|
|
648
783
|
}
|
|
649
|
-
const skill =
|
|
650
|
-
if (!skill)
|
|
651
|
-
return errorResult(
|
|
652
|
-
`Unknown skill "${args.name}". Available: ${AVAILABLE_SKILLS.map((item) => item.name).join(", ")}.`,
|
|
653
|
-
);
|
|
654
|
-
}
|
|
784
|
+
const skill = resolveSkill(args.name, connectors);
|
|
785
|
+
if (!skill.found) return errorResult(skill.message);
|
|
655
786
|
return { content: [{ type: "text", text: skill.content }] };
|
|
656
787
|
},
|
|
657
788
|
|
|
@@ -659,9 +790,9 @@ export function createMetaTools(
|
|
|
659
790
|
const probe = args.probe ?? true;
|
|
660
791
|
const out = await Promise.all(
|
|
661
792
|
registry.listConnectors().map(async (c) => {
|
|
662
|
-
const checkedAt = new Date().toISOString();
|
|
663
793
|
const statusStarted = Date.now();
|
|
664
794
|
const observed = registry.healthFor(c.id);
|
|
795
|
+
const verdict = await registry.credentialHealthFor(c.id);
|
|
665
796
|
let status:
|
|
666
797
|
| ConnectorStatus
|
|
667
798
|
| { state: "ok" | "error" | "unknown"; message?: string };
|
|
@@ -678,18 +809,76 @@ export function createMetaTools(
|
|
|
678
809
|
// hanging the whole list_connectors call.
|
|
679
810
|
status = { state: "error", message: msg(err) };
|
|
680
811
|
}
|
|
812
|
+
} else if (
|
|
813
|
+
verdict &&
|
|
814
|
+
// Deployment-wide, deliberately, like `hasObservedSuccess` beside
|
|
815
|
+
// it: a sibling toolkit's successful call proves the shared
|
|
816
|
+
// credential works, and a verdict retired for one view but not
|
|
817
|
+
// another would make the same connector read differently per scope
|
|
818
|
+
// for a reason that has nothing to do with scope.
|
|
819
|
+
credentialVerdictApplies(verdict, registry.observedSuccessAt(c.id))
|
|
820
|
+
) {
|
|
821
|
+
// The proactive layer (issue #24): a liveness check already found
|
|
822
|
+
// the stored credential dead, so say so on the cheap path instead of
|
|
823
|
+
// waiting for an agent's real call to discover it. Only while it is
|
|
824
|
+
// the freshest evidence — a successful call since then retires it.
|
|
825
|
+
status = {
|
|
826
|
+
state: verdict.state,
|
|
827
|
+
...(verdict.message ? { message: verdict.message } : {}),
|
|
828
|
+
...(verdict.authorizationUrl
|
|
829
|
+
? { authorizationUrl: verdict.authorizationUrl }
|
|
830
|
+
: {}),
|
|
831
|
+
};
|
|
681
832
|
} else {
|
|
833
|
+
// "error" comes from THIS view's own observations — a sibling
|
|
834
|
+
// toolkit's failure is not this session's experience — while
|
|
835
|
+
// ok/unknown may lean on the deployment-wide success signal, since
|
|
836
|
+
// "the connector answers at all" is a fact about the connector.
|
|
837
|
+
// Unscoped, the two are the same log, so this is unchanged there.
|
|
838
|
+
const derived =
|
|
839
|
+
observed?.consecutiveFailures && observed.consecutiveFailures > 0
|
|
840
|
+
? ("error" as const)
|
|
841
|
+
: registry.hasObservedSuccess(c.id) || c.kind === "api"
|
|
842
|
+
? ("ok" as const)
|
|
843
|
+
: ("unknown" as const);
|
|
682
844
|
status = {
|
|
845
|
+
// A successful liveness check upgrades "unknown" — nothing has
|
|
846
|
+
// been called yet, but the credential was verified, which is how
|
|
847
|
+
// re-authorization shows up here as ok rather than as an absence
|
|
848
|
+
// of evidence. It never DOWNgrades an observed failure: a real
|
|
849
|
+
// call that failed is stronger evidence than a background check.
|
|
683
850
|
state:
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
: observed?.lastSuccessAt || c.kind === "api"
|
|
688
|
-
? ("ok" as const)
|
|
689
|
-
: ("unknown" as const),
|
|
851
|
+
derived === "unknown" && verdict?.state === "ok"
|
|
852
|
+
? ("ok" as const)
|
|
853
|
+
: derived,
|
|
690
854
|
...(observed?.lastError ? { message: observed.lastError } : {}),
|
|
691
855
|
};
|
|
692
856
|
}
|
|
857
|
+
// Stamped where the observation actually happened — after the status
|
|
858
|
+
// probe, not before it. A 30-second probe stamped at its start would
|
|
859
|
+
// report a verdict older than it is, and would lose the race against a
|
|
860
|
+
// real call that succeeded WHILE it ran (that success must retire the
|
|
861
|
+
// verdict, and only an honest timestamp says so).
|
|
862
|
+
const checkedAt = new Date().toISOString();
|
|
863
|
+
// A live status probe IS a liveness observation of the stored
|
|
864
|
+
// credential, so it updates the same verdict a background check
|
|
865
|
+
// writes: the cached read afterwards agrees with what the operator
|
|
866
|
+
// just saw, and they are not swept again moments later. Recorded from
|
|
867
|
+
// the STATUS phase only, and only when the connector actually answered
|
|
868
|
+
// — a catalog refresh below is not a credential check (the sweep never
|
|
869
|
+
// fetches one), it is already counted in the health log, and letting
|
|
870
|
+
// its failure land here would spend the freshness budget on it. The
|
|
871
|
+
// registry ignores this for connectors storing no credential of ours.
|
|
872
|
+
if (probe && (status.state === "ok" || status.state === "auth_required")) {
|
|
873
|
+
await registry.recordCredentialHealth(c.id, {
|
|
874
|
+
state: status.state,
|
|
875
|
+
checkedAt,
|
|
876
|
+
...(status.message ? { message: status.message } : {}),
|
|
877
|
+
...("authorizationUrl" in status && status.authorizationUrl
|
|
878
|
+
? { authorizationUrl: status.authorizationUrl }
|
|
879
|
+
: {}),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
693
882
|
let tools = registry.peekTools(c.id);
|
|
694
883
|
// An auth_required status may have just started OAuth. A second
|
|
695
884
|
// listTools probe would overwrite its state/verifier while returning
|
|
@@ -709,6 +898,9 @@ export function createMetaTools(
|
|
|
709
898
|
}
|
|
710
899
|
const latencyMs = Date.now() - statusStarted;
|
|
711
900
|
const latestObserved = registry.healthFor(c.id);
|
|
901
|
+
const credentialCheck = probe
|
|
902
|
+
? await registry.credentialHealthFor(c.id)
|
|
903
|
+
: verdict;
|
|
712
904
|
return {
|
|
713
905
|
id: c.id,
|
|
714
906
|
...(c.title ? { title: c.title } : {}),
|
|
@@ -719,6 +911,7 @@ export function createMetaTools(
|
|
|
719
911
|
latencyMs,
|
|
720
912
|
probe,
|
|
721
913
|
...(latestObserved ?? observed ?? {}),
|
|
914
|
+
...(credentialCheck ? { credentialCheck } : {}),
|
|
722
915
|
...("authorizationUrl" in status && status.authorizationUrl
|
|
723
916
|
? { authorizationUrl: status.authorizationUrl }
|
|
724
917
|
: {}),
|
|
@@ -742,6 +935,7 @@ export function createMetaTools(
|
|
|
742
935
|
connectorId: string;
|
|
743
936
|
connectorTitle?: string;
|
|
744
937
|
connectorDescription?: string;
|
|
938
|
+
connectorGuideSkill?: string;
|
|
745
939
|
tool: ToolDef;
|
|
746
940
|
score: number;
|
|
747
941
|
order: number;
|
|
@@ -764,6 +958,9 @@ export function createMetaTools(
|
|
|
764
958
|
connectorId: c.id,
|
|
765
959
|
connectorTitle: c.title,
|
|
766
960
|
connectorDescription: c.description,
|
|
961
|
+
...(connectorGuide(c)
|
|
962
|
+
? { connectorGuideSkill: connectorSkillName(c.id) }
|
|
963
|
+
: {}),
|
|
767
964
|
tool: ranked.tool,
|
|
768
965
|
score: ranked.score,
|
|
769
966
|
order: orderBase + ranked.order,
|
|
@@ -778,6 +975,8 @@ export function createMetaTools(
|
|
|
778
975
|
id: string;
|
|
779
976
|
title?: string;
|
|
780
977
|
description?: string;
|
|
978
|
+
/** Skill name of this connector's usage guide, when it has one. */
|
|
979
|
+
guide?: string;
|
|
781
980
|
tools: Array<{
|
|
782
981
|
name: string;
|
|
783
982
|
address: string;
|
|
@@ -795,6 +994,9 @@ export function createMetaTools(
|
|
|
795
994
|
id: match.connectorId,
|
|
796
995
|
...(match.connectorTitle ? { title: match.connectorTitle } : {}),
|
|
797
996
|
description: match.connectorDescription,
|
|
997
|
+
...(match.connectorGuideSkill
|
|
998
|
+
? { guide: match.connectorGuideSkill }
|
|
999
|
+
: {}),
|
|
798
1000
|
tools: [],
|
|
799
1001
|
};
|
|
800
1002
|
byConnector.set(match.connectorId, group);
|
|
@@ -899,6 +1101,9 @@ export function createMetaTools(
|
|
|
899
1101
|
tool.description,
|
|
900
1102
|
args.fullDescriptions === true,
|
|
901
1103
|
),
|
|
1104
|
+
...(connectorGuide(resolved.connector)
|
|
1105
|
+
? { guide: connectorSkillName(resolved.connector.id) }
|
|
1106
|
+
: {}),
|
|
902
1107
|
inputSchema: format === "json" ? schema : compactSchema(schema),
|
|
903
1108
|
...(tool.outputSchema
|
|
904
1109
|
? {
|
|
@@ -925,6 +1130,26 @@ export function createMetaTools(
|
|
|
925
1130
|
},
|
|
926
1131
|
|
|
927
1132
|
async getResult(args: GetResultArgs): Promise<ToolResult> {
|
|
1133
|
+
// Client-supplied page size and offset: normal input-validation errors,
|
|
1134
|
+
// not clamps. Callers arriving over MCP are rejected earlier by the
|
|
1135
|
+
// registered zod schema and never reach these branches, so they exist for
|
|
1136
|
+
// in-process callers of createMetaTools — which have no schema in front
|
|
1137
|
+
// of them — and to keep the rules true of the handler on its own terms.
|
|
1138
|
+
if (
|
|
1139
|
+
args.maxBytes !== undefined &&
|
|
1140
|
+
!isValidMaxResultBytes(args.maxBytes)
|
|
1141
|
+
) {
|
|
1142
|
+
return errorResult(
|
|
1143
|
+
`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
|
|
1144
|
+
`>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
if (args.offset !== undefined && !isValidResultOffset(args.offset)) {
|
|
1148
|
+
return errorResult(
|
|
1149
|
+
`Invalid offset ${args.offset}: must be a whole number of bytes ` +
|
|
1150
|
+
`>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
928
1153
|
const results = registry.resultsStorage();
|
|
929
1154
|
const stored = await results.get(`result:${args.id}`);
|
|
930
1155
|
if (stored === null || stored === undefined) {
|
|
@@ -932,8 +1157,17 @@ export function createMetaTools(
|
|
|
932
1157
|
}
|
|
933
1158
|
const bytes = enc.encode(stored);
|
|
934
1159
|
const total = bytes.length;
|
|
935
|
-
|
|
936
|
-
|
|
1160
|
+
// Validated above, so no coercion is needed here — only alignment. A
|
|
1161
|
+
// client that computes its own offsets can land inside a multi-byte
|
|
1162
|
+
// character, which would decode as U+FFFD; the offset actually served is
|
|
1163
|
+
// the boundary at or before it, and it is what the response reports back
|
|
1164
|
+
// as `offset` (issue #38).
|
|
1165
|
+
const offset = alignStartToCharBoundary(bytes, args.offset ?? 0);
|
|
1166
|
+
// Page size only: a stashed result carries no connector identity, so
|
|
1167
|
+
// get_result keeps the deployment-wide default when none is requested.
|
|
1168
|
+
// Both sides are validated by now — the argument above, `globalCap` at
|
|
1169
|
+
// intake — so `offset + maxBytes` always reaches past `offset`.
|
|
1170
|
+
const maxBytes = args.maxBytes ?? globalCap;
|
|
937
1171
|
// Align the slice end to a codepoint boundary so a multi-byte char is
|
|
938
1172
|
// never split across pages (which would emit U+FFFD on both sides).
|
|
939
1173
|
// `nextOffset` is this aligned end, so it is a valid boundary for the
|
|
@@ -1036,6 +1270,17 @@ export function createMetaTools(
|
|
|
1036
1270
|
const ctx = registry.contextFor(connector.id, baseUrl, requestScope);
|
|
1037
1271
|
try {
|
|
1038
1272
|
const status = await connector.startAuth(ctx, { force: args.force });
|
|
1273
|
+
// startAuth just spoke to the downstream about this exact credential, so
|
|
1274
|
+
// its answer replaces any older liveness verdict — including the stale
|
|
1275
|
+
// `auth_required` that sent the agent here, once it reports ok.
|
|
1276
|
+
await registry.recordCredentialHealth(connector.id, {
|
|
1277
|
+
state: status.state,
|
|
1278
|
+
checkedAt: new Date().toISOString(),
|
|
1279
|
+
...(status.message ? { message: status.message } : {}),
|
|
1280
|
+
...(status.authorizationUrl
|
|
1281
|
+
? { authorizationUrl: status.authorizationUrl }
|
|
1282
|
+
: {}),
|
|
1283
|
+
});
|
|
1039
1284
|
if (status.state === "auth_required" && !status.authorizationUrl) {
|
|
1040
1285
|
// auth_required with nothing to open is a dead end for the operator.
|
|
1041
1286
|
return errorResult(
|
|
@@ -1076,7 +1321,7 @@ const CALL_DESC =
|
|
|
1076
1321
|
const CALL_DESTRUCTIVE_DESC =
|
|
1077
1322
|
"Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. The MCP destructiveHint on this meta-tool lets the host request human approval before execution. Use only after reviewing the downstream tool schema and consequences.";
|
|
1078
1323
|
const GET_RESULT_DESC =
|
|
1079
|
-
"Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. Unknown/expired id is an error.";
|
|
1324
|
+
"Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
|
|
1080
1325
|
const BATCH_DESC =
|
|
1081
1326
|
"Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call.";
|
|
1082
1327
|
const AUTHORIZE_DESC =
|
|
@@ -1084,6 +1329,37 @@ const AUTHORIZE_DESC =
|
|
|
1084
1329
|
const SKILLS_DESC =
|
|
1085
1330
|
'List or fetch concise guidance for choosing among Connecta meta-tools. Call skills({ name: "usage" }) once when the routing workflow is unfamiliar; do not refetch it in the same task.';
|
|
1086
1331
|
|
|
1332
|
+
/**
|
|
1333
|
+
* Sentences appended to a meta-tool description only when this connection
|
|
1334
|
+
* actually has connector guides. Tool descriptions are always-loaded context,
|
|
1335
|
+
* so a deployment with no guides gets every base description unchanged rather
|
|
1336
|
+
* than paying for text about a feature it does not use.
|
|
1337
|
+
*
|
|
1338
|
+
* Registration is per connection and reads the connection's own registry view,
|
|
1339
|
+
* so under a toolkit these sentences reflect the SCOPED connector set: a scoped
|
|
1340
|
+
* session whose connectors carry no guides sees the base descriptions, and
|
|
1341
|
+
* never learns from a tool description that guides exist out of scope.
|
|
1342
|
+
*/
|
|
1343
|
+
const GUIDE_NOTES = {
|
|
1344
|
+
skills:
|
|
1345
|
+
' skills({}) also lists this deployment\'s per-connector usage guides as "connector:<connectorId>"; fetch the guide for a connector before working with it for the first time.',
|
|
1346
|
+
search:
|
|
1347
|
+
" A connector group carrying `guide` has a usage guide; fetch it with skills({ name: <guide> }).",
|
|
1348
|
+
describe:
|
|
1349
|
+
" An entry carrying `guide` belongs to a connector with a usage guide; fetch it with skills({ name: <guide> }).",
|
|
1350
|
+
} as const;
|
|
1351
|
+
|
|
1352
|
+
/** `base`, plus its guide note when any VISIBLE connector carries a guide. */
|
|
1353
|
+
function describedFor(
|
|
1354
|
+
registry: RegistryView,
|
|
1355
|
+
base: string,
|
|
1356
|
+
note: keyof typeof GUIDE_NOTES,
|
|
1357
|
+
): string {
|
|
1358
|
+
return hasConnectorGuides(registry.listConnectors())
|
|
1359
|
+
? base + GUIDE_NOTES[note]
|
|
1360
|
+
: base;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1087
1363
|
/**
|
|
1088
1364
|
* Connecta refuses downstream tools that are not explicitly annotated
|
|
1089
1365
|
* read-only, so its own meta-tools must carry the same hints — otherwise a
|
|
@@ -1107,17 +1383,15 @@ const READ_ONLY_LOCAL = {
|
|
|
1107
1383
|
/** Register the nine meta-tools onto an McpServer instance. */
|
|
1108
1384
|
export function registerMetaTools(
|
|
1109
1385
|
server: McpServer,
|
|
1110
|
-
registry:
|
|
1386
|
+
registry: RegistryView,
|
|
1111
1387
|
ctx: {
|
|
1112
1388
|
baseUrl: string;
|
|
1113
|
-
maxResultBytes?: number;
|
|
1114
1389
|
defaultToolTimeoutMs?: number;
|
|
1115
1390
|
probeTimeoutMs?: number;
|
|
1116
1391
|
activity?: ActivityRequestContext;
|
|
1117
1392
|
},
|
|
1118
1393
|
): void {
|
|
1119
1394
|
const mt = createMetaTools(registry, ctx.baseUrl, {
|
|
1120
|
-
maxResultBytes: ctx.maxResultBytes,
|
|
1121
1395
|
defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
|
|
1122
1396
|
probeTimeoutMs: ctx.probeTimeoutMs,
|
|
1123
1397
|
activity: ctx.activity,
|
|
@@ -1126,7 +1400,7 @@ export function registerMetaTools(
|
|
|
1126
1400
|
server.registerTool(
|
|
1127
1401
|
"skills",
|
|
1128
1402
|
{
|
|
1129
|
-
description: SKILLS_DESC,
|
|
1403
|
+
description: describedFor(registry, SKILLS_DESC, "skills"),
|
|
1130
1404
|
inputSchema: { name: z.string().optional() },
|
|
1131
1405
|
annotations: {
|
|
1132
1406
|
readOnlyHint: true,
|
|
@@ -1151,7 +1425,7 @@ export function registerMetaTools(
|
|
|
1151
1425
|
server.registerTool(
|
|
1152
1426
|
"search_tools",
|
|
1153
1427
|
{
|
|
1154
|
-
description: SEARCH_DESC,
|
|
1428
|
+
description: describedFor(registry, SEARCH_DESC, "search"),
|
|
1155
1429
|
inputSchema: {
|
|
1156
1430
|
query: z.string().optional(),
|
|
1157
1431
|
connector: z.string().optional(),
|
|
@@ -1168,7 +1442,7 @@ export function registerMetaTools(
|
|
|
1168
1442
|
server.registerTool(
|
|
1169
1443
|
"describe_tools",
|
|
1170
1444
|
{
|
|
1171
|
-
description: DESCRIBE_DESC,
|
|
1445
|
+
description: describedFor(registry, DESCRIBE_DESC, "describe"),
|
|
1172
1446
|
inputSchema: {
|
|
1173
1447
|
addresses: z.array(z.string()),
|
|
1174
1448
|
format: z.enum(["compact", "json"]).optional(),
|
|
@@ -1246,8 +1520,12 @@ export function registerMetaTools(
|
|
|
1246
1520
|
description: GET_RESULT_DESC,
|
|
1247
1521
|
inputSchema: {
|
|
1248
1522
|
id: z.string(),
|
|
1249
|
-
|
|
1250
|
-
|
|
1523
|
+
// Both bounds are the shared rules (isValidResultOffset,
|
|
1524
|
+
// isValidMaxResultBytes) expressed for the wire: spelling them against
|
|
1525
|
+
// the same constants keeps the schema from drifting away from the
|
|
1526
|
+
// in-handler checks if either floor ever moves.
|
|
1527
|
+
offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
|
|
1528
|
+
maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
|
|
1251
1529
|
},
|
|
1252
1530
|
annotations: READ_ONLY_LOCAL,
|
|
1253
1531
|
},
|