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