@zackbart/connecta 0.24.1 → 0.24.3
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 +169 -0
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/clerk.d.ts +0 -5
- package/dist/auth/clerk.js +21 -8
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- package/dist/call-admission.d.ts +4 -0
- package/dist/call-admission.js +26 -0
- package/dist/catalog-drift.js +9 -4
- package/dist/catalog-service.d.ts +2 -0
- package/dist/catalog-service.js +25 -8
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -121
- package/dist/connector-access.d.ts +32 -0
- package/dist/connector-access.js +79 -0
- package/dist/connectors/api.js +11 -1
- package/dist/connectors/guarded-fetch.d.ts +1 -1
- package/dist/connectors/guarded-fetch.js +27 -20
- package/dist/connectors/remote-mcp.js +84 -53
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +58 -0
- package/dist/execute.js +85 -23
- package/dist/executor-result.js +3 -1
- package/dist/executors/quickjs-child.js +5 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -0
- package/dist/executors/quickjs-runtime.d.ts +1 -1
- package/dist/executors/quickjs-runtime.js +38 -21
- package/dist/executors/quickjs.js +68 -27
- package/dist/index.d.ts +37 -1
- package/dist/index.js +89 -3
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +118 -39
- package/dist/registry.d.ts +29 -1
- package/dist/registry.js +122 -15
- package/dist/routes/credentials.js +1 -0
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +112 -12
- package/dist/routes/oauth-management.js +1 -0
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +7 -1
- package/dist/routes/shared.js +12 -13
- package/dist/routes/ui.js +2 -1
- package/dist/server.js +15 -3
- package/dist/skills.js +6 -5
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.js +312 -34
- package/dist/storage/memory.js +12 -1
- package/dist/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +30 -9
- package/documentation/auth.md +110 -6
- package/documentation/call-admission.md +24 -8
- package/documentation/code-mode.md +34 -22
- package/documentation/connectors.md +47 -5
- package/documentation/meta-tools.md +74 -6
- package/documentation/operations.md +20 -19
- package/documentation/provider-conventions.md +7 -0
- package/documentation/request-admission.md +38 -4
- package/documentation/storage-and-credentials.md +54 -1
- package/documentation/upgrading.md +21 -5
- package/ethos.md +1 -1
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/dist/meta-tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, groupedSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
|
|
3
3
|
import { resolveDiscoveryConcurrency } from "./concurrency.js";
|
|
4
|
-
import { msg } from "./errors.js";
|
|
4
|
+
import { boundedEchoText, msg } from "./errors.js";
|
|
5
5
|
import { serializeResultText } from "./executor-result.js";
|
|
6
6
|
import { InvocationService, } from "./invocation.js";
|
|
7
7
|
import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
|
|
@@ -37,7 +37,12 @@ async function discoveryResult(operation, hint) {
|
|
|
37
37
|
try {
|
|
38
38
|
const value = await operation();
|
|
39
39
|
const text = boundedDiscoveryText(value, hint);
|
|
40
|
-
|
|
40
|
+
const result = jsonResult(value, text);
|
|
41
|
+
const bytes = enc.encode(JSON.stringify(result)).length;
|
|
42
|
+
if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
|
|
43
|
+
throw new DiscoveryPolicyError("result_too_large", `Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
41
46
|
}
|
|
42
47
|
catch (err) {
|
|
43
48
|
if (err instanceof DiscoveryPolicyError) {
|
|
@@ -94,29 +99,37 @@ export function alignEndToCharBoundary(bytes, offset, end, total) {
|
|
|
94
99
|
}
|
|
95
100
|
return e;
|
|
96
101
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* text for whatever JSON can represent, and `String(value)` for the returns
|
|
101
|
-
* JSON renders as `undefined` — a handler that returns nothing, a function, or
|
|
102
|
-
* a Symbol. `JSON.stringify` is *typed* as returning `string` while actually
|
|
103
|
-
* returning `undefined` for those, which is how a handler returning `undefined`
|
|
104
|
-
* reached clients as a `{"type":"text"}` block carrying no `text` at all: the
|
|
105
|
-
* size guard measured `enc.encode(undefined)` — the empty string, per the
|
|
106
|
-
* WebIDL default — and emitted the non-string unchanged (issue #42). `null`
|
|
107
|
-
* needs no special case; JSON renders it as `"null"`.
|
|
108
|
-
*
|
|
109
|
-
* Shared by `guardText`, `guardValue`, and execute_code's `guardResultValue` so
|
|
110
|
-
* the three give one answer to the same question. A value JSON cannot serialize
|
|
111
|
-
* at all (a BigInt) still throws, as before, and is reported as a failure.
|
|
112
|
-
*/
|
|
113
|
-
/**
|
|
114
|
-
* Stash `text` under `result:<uuid>` (ttl 900s) and describe it as the
|
|
115
|
-
* truncation notice every over-cap path hands back.
|
|
116
|
-
*/
|
|
117
|
-
async function stashResult(text, results, totalBytes) {
|
|
102
|
+
/** Stash a completed result, or return a notice without a paging route. */
|
|
103
|
+
async function stashResult(bytes, results) {
|
|
104
|
+
const totalBytes = bytes.length;
|
|
118
105
|
const id = crypto.randomUUID();
|
|
119
|
-
|
|
106
|
+
try {
|
|
107
|
+
// Base64 permits byte-range decoding after a KV read, without scanning or
|
|
108
|
+
// re-encoding all preceding text. Each chunk is a multiple of three bytes.
|
|
109
|
+
const chunks = [];
|
|
110
|
+
for (let offset = 0; offset < bytes.length; offset += 12_288) {
|
|
111
|
+
chunks.push(btoa(String.fromCharCode(...bytes.subarray(offset, offset + 12_288))));
|
|
112
|
+
}
|
|
113
|
+
const stored = `connecta-result-v1:${totalBytes}:${chunks.join("")}`;
|
|
114
|
+
if (!await results.set(`result:${id}`, stored, RESULT_TTL_SECONDS)) {
|
|
115
|
+
throw new Error("Result stash capacity exhausted");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Paging is advisory after a completed call, including an approved write.
|
|
120
|
+
// Neither backend prose nor a retry hint belongs in this successful result.
|
|
121
|
+
try {
|
|
122
|
+
results.warn();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Logging cannot change the call either.
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
truncated: true,
|
|
129
|
+
totalBytes,
|
|
130
|
+
hint: "Paging is unavailable. Use execute_code to reduce read-only results before returning them. Do not repeat a completed write to recover its result.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
120
133
|
return {
|
|
121
134
|
truncated: true,
|
|
122
135
|
resultId: id,
|
|
@@ -141,7 +154,7 @@ async function guardEncoded(text, bytes, results, cap) {
|
|
|
141
154
|
truncated: false,
|
|
142
155
|
};
|
|
143
156
|
}
|
|
144
|
-
const notice = await stashResult(
|
|
157
|
+
const notice = await stashResult(bytes, results);
|
|
145
158
|
const head = dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)));
|
|
146
159
|
return {
|
|
147
160
|
result: {
|
|
@@ -166,8 +179,12 @@ async function guardValue(value, results, cap) {
|
|
|
166
179
|
const bytes = enc.encode(text);
|
|
167
180
|
if (bytes.length <= cap)
|
|
168
181
|
return { result: value, truncated: false };
|
|
182
|
+
const notice = await stashResult(bytes, results);
|
|
169
183
|
return {
|
|
170
|
-
result:
|
|
184
|
+
result: notice.resultId ? notice : {
|
|
185
|
+
...notice,
|
|
186
|
+
preview: dec.decode(bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length))),
|
|
187
|
+
},
|
|
171
188
|
truncated: true,
|
|
172
189
|
};
|
|
173
190
|
}
|
|
@@ -210,7 +227,7 @@ async function guardContent(content, results, cap) {
|
|
|
210
227
|
if (content.every((b) => b.type === "text")) {
|
|
211
228
|
return guardEncoded(text, bytes, results, cap);
|
|
212
229
|
}
|
|
213
|
-
const notice = await stashResult(
|
|
230
|
+
const notice = await stashResult(bytes, results);
|
|
214
231
|
return {
|
|
215
232
|
result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
|
|
216
233
|
truncated: true,
|
|
@@ -259,7 +276,6 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
259
276
|
const invocation = new InvocationService(registry, catalog, opts.activity);
|
|
260
277
|
/** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
|
|
261
278
|
async function runCall(call, source, options = {}) {
|
|
262
|
-
const results = registry.resultsStorage();
|
|
263
279
|
const timeoutMs = normalizeTimeoutMs(call.timeoutMs) ?? defaultToolTimeoutMs;
|
|
264
280
|
const outcome = await invocation.invoke(call.address, call.args ?? {}, {
|
|
265
281
|
source,
|
|
@@ -272,6 +288,13 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
272
288
|
: {}),
|
|
273
289
|
unwrapResult: call.resultMode === "value",
|
|
274
290
|
processResult: async (result, resolved) => {
|
|
291
|
+
const results = {
|
|
292
|
+
set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
|
|
293
|
+
warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
|
|
294
|
+
connector: resolved.connector.id,
|
|
295
|
+
tool: resolved.toolName,
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
275
298
|
// Result-size cap for THIS call: the connector's own override wins,
|
|
276
299
|
// then the deployment-wide value, then the built-in default (already
|
|
277
300
|
// folded into `globalCap`). Resolved per call so one request can
|
|
@@ -292,7 +315,14 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
292
315
|
}
|
|
293
316
|
if (resolved.connector.kind === "mcp") {
|
|
294
317
|
const mcpResult = result;
|
|
295
|
-
|
|
318
|
+
let content = mcpResult?.content ?? [];
|
|
319
|
+
if (!content.some((block) => block.type === "text") &&
|
|
320
|
+
mcpResult?.structuredContent !== undefined) {
|
|
321
|
+
content = [...content, {
|
|
322
|
+
type: "text",
|
|
323
|
+
text: JSON.stringify(mcpResult.structuredContent),
|
|
324
|
+
}];
|
|
325
|
+
}
|
|
296
326
|
const guarded = await guardContent(content, results, cap);
|
|
297
327
|
return processed(guarded.result, guarded.truncated);
|
|
298
328
|
}
|
|
@@ -305,12 +335,15 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
305
335
|
if (!outcome.ok) {
|
|
306
336
|
const structuredRecovery = outcome.error.nextAction !== undefined;
|
|
307
337
|
const recoveryRequired = structuredRecovery ||
|
|
338
|
+
// Sanitized `unavailable` diagnostics ride the structured shape too;
|
|
339
|
+
// the plain-text path would drop them (#539).
|
|
340
|
+
outcome.error.details !== undefined ||
|
|
308
341
|
[
|
|
309
342
|
"auth_required",
|
|
310
343
|
"invalid_args",
|
|
311
344
|
"input_required_unsupported",
|
|
312
345
|
].includes(outcome.error.code);
|
|
313
|
-
const
|
|
346
|
+
const makeFailedResult = () => recoveryRequired ||
|
|
314
347
|
call.resultMode === "value"
|
|
315
348
|
? jsonResult({
|
|
316
349
|
ok: false,
|
|
@@ -320,6 +353,18 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
320
353
|
...(call.diagnostics ? { timing: outcome.timing } : {}),
|
|
321
354
|
})
|
|
322
355
|
: errorResult(outcome.error.message);
|
|
356
|
+
let failedResult = makeFailedResult();
|
|
357
|
+
// Value mode repeats the error in text and structuredContent. Account for
|
|
358
|
+
// both copies and JSON escaping when the bounded provider reason is large.
|
|
359
|
+
if (!recoveryRequired) {
|
|
360
|
+
const cap = resolveMaxResultBytes(outcome.resolved?.connector.maxResultBytes, globalCap);
|
|
361
|
+
let budget = 512;
|
|
362
|
+
while (enc.encode(JSON.stringify(failedResult)).length > cap && budget > 0) {
|
|
363
|
+
budget = Math.floor(budget / 2);
|
|
364
|
+
outcome.error.message = boundedEchoText(outcome.error.message, budget);
|
|
365
|
+
failedResult = makeFailedResult();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
323
368
|
if (recoveryRequired) {
|
|
324
369
|
failedResult.isError = true;
|
|
325
370
|
}
|
|
@@ -372,6 +417,9 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
372
417
|
return { content: [{ type: "text", text: skill.content }] };
|
|
373
418
|
},
|
|
374
419
|
async searchTools(args) {
|
|
420
|
+
if (args.connector !== undefined && enc.encode(args.connector).length > 512) {
|
|
421
|
+
return discoveryErrorResult(new DiscoveryPolicyError("invalid_args", "connector must be at most 512 UTF-8 bytes."));
|
|
422
|
+
}
|
|
375
423
|
return discoveryResult(async () => groupedSearchResult(await catalog.search({
|
|
376
424
|
...args,
|
|
377
425
|
includeSchemaKeys: args.includeSchemas !== undefined,
|
|
@@ -401,29 +449,60 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
401
449
|
`>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
|
|
402
450
|
}
|
|
403
451
|
const results = registry.resultsStorage();
|
|
404
|
-
|
|
452
|
+
let stored;
|
|
453
|
+
try {
|
|
454
|
+
stored = await results.get(`result:${args.id}`);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return {
|
|
458
|
+
...jsonResult({
|
|
459
|
+
error: {
|
|
460
|
+
code: "unavailable",
|
|
461
|
+
message: "Result paging storage is unavailable.",
|
|
462
|
+
retryable: true,
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
isError: true,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
405
468
|
if (stored === null || stored === undefined) {
|
|
406
|
-
return errorResult(`Unknown or expired result id "${args.id}"`);
|
|
469
|
+
return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
|
|
470
|
+
}
|
|
471
|
+
const requestedOffset = args.offset ?? 0;
|
|
472
|
+
const maxBytes = args.maxBytes ?? globalCap;
|
|
473
|
+
// Decode only this page plus UTF-8 boundary lookaround. Legacy raw-text
|
|
474
|
+
// entries remain readable for their short TTL after an upgrade.
|
|
475
|
+
const header = /^connecta-result-v1:(\d+):/.exec(stored.slice(0, 64));
|
|
476
|
+
let bytes;
|
|
477
|
+
let total;
|
|
478
|
+
let start = 0;
|
|
479
|
+
if (header) {
|
|
480
|
+
total = Number(header[1]);
|
|
481
|
+
start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
|
|
482
|
+
const end = Math.min(total, requestedOffset + maxBytes + 4);
|
|
483
|
+
const binary = atob(stored.slice(header[0].length + start / 3 * 4, header[0].length + Math.ceil(end / 3) * 4));
|
|
484
|
+
bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
bytes = enc.encode(stored);
|
|
488
|
+
total = bytes.length;
|
|
407
489
|
}
|
|
408
|
-
const bytes = enc.encode(stored);
|
|
409
|
-
const total = bytes.length;
|
|
410
490
|
// Validated above, so no coercion is needed here — only alignment. A
|
|
411
491
|
// client that computes its own offsets can land inside a multi-byte
|
|
412
492
|
// character, which would decode as U+FFFD; the offset actually served is
|
|
413
493
|
// the boundary at or before it, and it is what the response reports back
|
|
414
494
|
// as `offset` (issue #38).
|
|
415
|
-
const offset = alignStartToCharBoundary(bytes,
|
|
495
|
+
const offset = start + alignStartToCharBoundary(bytes, requestedOffset - start);
|
|
416
496
|
// Page size only: a stashed result carries no connector identity, so
|
|
417
497
|
// get_result keeps the deployment-wide default when none is requested.
|
|
418
498
|
// Both sides are validated by now — the argument above, `globalCap` at
|
|
419
499
|
// intake — so `offset + maxBytes` always reaches past `offset`.
|
|
420
|
-
const maxBytes = args.maxBytes ?? globalCap;
|
|
421
500
|
// Align the slice end to a codepoint boundary so a multi-byte char is
|
|
422
501
|
// never split across pages (which would emit U+FFFD on both sides).
|
|
423
502
|
// `nextOffset` is this aligned end, so it is a valid boundary for the
|
|
424
503
|
// next call and paging reassembles the original byte-for-byte.
|
|
425
|
-
const end = alignEndToCharBoundary(bytes, offset, offset + maxBytes, total);
|
|
426
|
-
const slice = dec.decode(bytes.
|
|
504
|
+
const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
|
|
505
|
+
const slice = dec.decode(bytes.subarray(offset - start, end - start));
|
|
427
506
|
const nextOffset = end < total ? end : undefined;
|
|
428
507
|
return jsonResult({
|
|
429
508
|
offset,
|
|
@@ -435,7 +514,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
435
514
|
async authorizeConnector(args) {
|
|
436
515
|
const connector = registry.getConnector(args.connector);
|
|
437
516
|
if (!connector) {
|
|
438
|
-
return errorResult(`Unknown connector "${args.connector}"`);
|
|
517
|
+
return errorResult(`Unknown connector "${boundedEchoText(args.connector)}"`);
|
|
439
518
|
}
|
|
440
519
|
if (!connector.startAuth) {
|
|
441
520
|
if (!connector.credential) {
|
package/dist/registry.d.ts
CHANGED
|
@@ -61,6 +61,10 @@ export interface RegistryOptions {
|
|
|
61
61
|
* to the default 50_000.
|
|
62
62
|
*/
|
|
63
63
|
maxResultBytes?: number | undefined;
|
|
64
|
+
results?: {
|
|
65
|
+
maxStashBytes?: number;
|
|
66
|
+
maxStashEntries?: number;
|
|
67
|
+
} | undefined;
|
|
64
68
|
/**
|
|
65
69
|
* Where payload-free catalog-drift observations go. Present only when the
|
|
66
70
|
* deployment configured an activity store; drift is reported through
|
|
@@ -103,6 +107,8 @@ export interface RegistryView {
|
|
|
103
107
|
signal?: AbortSignal;
|
|
104
108
|
}): Promise<CallAdmissionPermit>;
|
|
105
109
|
resultsStorage(): KVStorage;
|
|
110
|
+
/** Reserve runtime-wide capacity before writing a paging envelope. */
|
|
111
|
+
stashResult(key: string, value: string, ttlSeconds: number): Promise<boolean>;
|
|
106
112
|
/** Local declared-vs-stored credential mismatch, with no downstream I/O. */
|
|
107
113
|
credentialDriftFor(id: string): Promise<string | undefined>;
|
|
108
114
|
/** Value-free shape learned from successful calls, never a provider declaration. */
|
|
@@ -114,8 +120,15 @@ export interface RegistryView {
|
|
|
114
120
|
/** Bind returned OAuth state to this view's personal storage partition. */
|
|
115
121
|
bindOAuthHandoff(id: string, authorizationUrl: string): Promise<void>;
|
|
116
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Connector id → the only tool names this view may see on it. A connector
|
|
125
|
+
* absent from the map is visible whole. Derived from `connectorAccess`
|
|
126
|
+
* addresses at the auth gate; never from caller input.
|
|
127
|
+
*/
|
|
128
|
+
export type ToolAccess = ReadonlyMap<string, ReadonlySet<string>>;
|
|
117
129
|
export interface RegistryScope {
|
|
118
130
|
connectorIds: "all" | readonly string[];
|
|
131
|
+
toolAccess?: ToolAccess;
|
|
119
132
|
subjectKey?: string;
|
|
120
133
|
principalKey?: string;
|
|
121
134
|
}
|
|
@@ -149,13 +162,26 @@ export declare class Registry implements RegistryView {
|
|
|
149
162
|
private readonly persistToolCatalog;
|
|
150
163
|
/** Result-size guard cap threaded to the meta-tools. */
|
|
151
164
|
readonly maxResultBytes: number;
|
|
165
|
+
/** Only keys, byte counts, and expiry survive requests; never write promises. */
|
|
166
|
+
private readonly resultStash;
|
|
167
|
+
private resultStashBytes;
|
|
152
168
|
private readonly configuredConnectors;
|
|
153
169
|
private readonly personalRegistries;
|
|
170
|
+
private callAdmissionClosed;
|
|
171
|
+
/** Bounded FIFO of absent grants already warned about. */
|
|
172
|
+
private readonly warnedAbsentGrants;
|
|
154
173
|
constructor(connectors: Connector[], opts: RegistryOptions);
|
|
155
174
|
personalRegistry(principalKey: string): Registry;
|
|
156
175
|
/** Build the only connector view an authenticated request receives. */
|
|
157
176
|
scoped(scope: RegistryScope): RegistryView;
|
|
158
177
|
scopedStorage(subjectKey: string): KVStorage;
|
|
178
|
+
/**
|
|
179
|
+
* A granted `connector.tool` address the live catalog does not contain is
|
|
180
|
+
* unreachable, which is the fail-closed outcome; this only makes the
|
|
181
|
+
* misconfiguration visible. Remote catalogs load lazily, so construction
|
|
182
|
+
* cannot check it, and a catalog that drifts later cannot widen a grant.
|
|
183
|
+
*/
|
|
184
|
+
noteAbsentGrant(connectorId: string, toolName: string): void;
|
|
159
185
|
private oauthHandoffKey;
|
|
160
186
|
storeOAuthHandoff(connectorId: string, state: string, principalKey: string): Promise<void>;
|
|
161
187
|
oauthCallbackView(connectorId: string, state: string | null): Promise<{
|
|
@@ -186,7 +212,7 @@ export declare class Registry implements RegistryView {
|
|
|
186
212
|
args: unknown;
|
|
187
213
|
signal?: AbortSignal;
|
|
188
214
|
}): Promise<CallAdmissionPermit>;
|
|
189
|
-
/**
|
|
215
|
+
/** Connector totals across root and personal controllers; health removes ids. */
|
|
190
216
|
callAdmissionSnapshot(): Record<string, ConnectorCallAdmissionSnapshot>;
|
|
191
217
|
/**
|
|
192
218
|
* Payload-free drift counts for the open health endpoint, so `connecta
|
|
@@ -214,6 +240,8 @@ export declare class Registry implements RegistryView {
|
|
|
214
240
|
private observeCatalogDrift;
|
|
215
241
|
/** Reject queued/future downstream admission; active permits release safely. */
|
|
216
242
|
closeCallAdmission(): void;
|
|
243
|
+
/** Reserve capacity and write one ASCII paging envelope in this runtime. */
|
|
244
|
+
stashResult(key: string, value: string, ttlSeconds: number, prefix?: string): Promise<boolean>;
|
|
217
245
|
/**
|
|
218
246
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
219
247
|
* separate from any connector's `conn:<id>:` namespace. Backs get_result.
|
package/dist/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { closeConnectorScope, } from "./connector-scope.js";
|
|
2
2
|
import { storedCredentialShape, } from "./credential-rules.js";
|
|
3
3
|
import { ConnectorCallError, msg } from "./errors.js";
|
|
4
|
-
import { ConnectorCallAdmissionController, } from "./call-admission.js";
|
|
4
|
+
import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
|
|
5
5
|
import { boundedCatalogDrift } from "./catalog-drift.js";
|
|
6
6
|
import { fingerprintSerializedCatalog, snapshotCatalog, } from "./catalog-fingerprint.js";
|
|
7
7
|
import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
|
|
@@ -80,6 +80,7 @@ function namespaced(storage, prefix) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
const MAX_PERSONAL_REGISTRIES = 1_024;
|
|
83
|
+
const MAX_ABSENT_GRANT_WARNINGS = 1_024;
|
|
83
84
|
const OAUTH_HANDOFF_TTL_SECONDS = 15 * 60;
|
|
84
85
|
async function sha256Hex(value) {
|
|
85
86
|
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
@@ -117,8 +118,14 @@ export class Registry {
|
|
|
117
118
|
persistToolCatalog;
|
|
118
119
|
/** Result-size guard cap threaded to the meta-tools. */
|
|
119
120
|
maxResultBytes;
|
|
121
|
+
/** Only keys, byte counts, and expiry survive requests; never write promises. */
|
|
122
|
+
resultStash = new Map();
|
|
123
|
+
resultStashBytes = 0;
|
|
120
124
|
configuredConnectors;
|
|
121
125
|
personalRegistries = new Map();
|
|
126
|
+
callAdmissionClosed = false;
|
|
127
|
+
/** Bounded FIFO of absent grants already warned about. */
|
|
128
|
+
warnedAbsentGrants = new Set();
|
|
122
129
|
constructor(connectors, opts) {
|
|
123
130
|
this.opts = opts;
|
|
124
131
|
this.configuredConnectors = [...connectors];
|
|
@@ -172,18 +179,24 @@ export class Registry {
|
|
|
172
179
|
this.personalRegistries.set(principalKey, existing);
|
|
173
180
|
return existing;
|
|
174
181
|
}
|
|
182
|
+
if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
|
|
183
|
+
// Eviction must not reset a live rolling budget or orphan queued calls.
|
|
184
|
+
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()));
|
|
185
|
+
if (!idle) {
|
|
186
|
+
throw new Error("Personal connector capacity is exhausted; retry after calls and rolling budgets drain.");
|
|
187
|
+
}
|
|
188
|
+
idle[1].closeCallAdmission();
|
|
189
|
+
this.personalRegistries.delete(idle[0]);
|
|
190
|
+
}
|
|
175
191
|
const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
|
|
176
192
|
...this.opts,
|
|
177
193
|
storage: namespaced(this.opts.storage, `principal:${principalKey}:`),
|
|
178
194
|
credentialOwner: principalKey,
|
|
179
195
|
constructionChecks: false,
|
|
180
196
|
});
|
|
197
|
+
if (this.callAdmissionClosed)
|
|
198
|
+
registry.closeCallAdmission();
|
|
181
199
|
this.personalRegistries.set(principalKey, registry);
|
|
182
|
-
const oldest = this.personalRegistries.keys().next().value;
|
|
183
|
-
if (this.personalRegistries.size > MAX_PERSONAL_REGISTRIES &&
|
|
184
|
-
typeof oldest === "string") {
|
|
185
|
-
this.personalRegistries.delete(oldest);
|
|
186
|
-
}
|
|
187
200
|
return registry;
|
|
188
201
|
}
|
|
189
202
|
/** Build the only connector view an authenticated request receives. */
|
|
@@ -201,6 +214,27 @@ export class Registry {
|
|
|
201
214
|
scopedStorage(subjectKey) {
|
|
202
215
|
return namespaced(this.opts.storage, `subject:${subjectKey}:`);
|
|
203
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* A granted `connector.tool` address the live catalog does not contain is
|
|
219
|
+
* unreachable, which is the fail-closed outcome; this only makes the
|
|
220
|
+
* misconfiguration visible. Remote catalogs load lazily, so construction
|
|
221
|
+
* cannot check it, and a catalog that drifts later cannot widen a grant.
|
|
222
|
+
*/
|
|
223
|
+
noteAbsentGrant(connectorId, toolName) {
|
|
224
|
+
const key = `${connectorId}.${toolName}`;
|
|
225
|
+
if (this.warnedAbsentGrants.has(key))
|
|
226
|
+
return;
|
|
227
|
+
this.warnedAbsentGrants.add(key);
|
|
228
|
+
if (this.warnedAbsentGrants.size > MAX_ABSENT_GRANT_WARNINGS) {
|
|
229
|
+
const oldest = this.warnedAbsentGrants.values().next().value;
|
|
230
|
+
if (oldest !== undefined)
|
|
231
|
+
this.warnedAbsentGrants.delete(oldest);
|
|
232
|
+
}
|
|
233
|
+
// Grant names are operator data but may carry any non-control character;
|
|
234
|
+
// quote them so a line terminator a log reader honours cannot forge a line.
|
|
235
|
+
const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
|
|
236
|
+
this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
|
|
237
|
+
}
|
|
204
238
|
oauthHandoffKey(connectorId, stateHash) {
|
|
205
239
|
return `oauth-handoff:v1:${connectorId}:${stateHash}`;
|
|
206
240
|
}
|
|
@@ -327,11 +361,19 @@ export class Registry {
|
|
|
327
361
|
return admission.acquire(input);
|
|
328
362
|
return Promise.resolve({ waitMs: 0, release() { } });
|
|
329
363
|
}
|
|
330
|
-
/**
|
|
364
|
+
/** Connector totals across root and personal controllers; health removes ids. */
|
|
331
365
|
callAdmissionSnapshot() {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
admission.snapshot()
|
|
366
|
+
const snapshots = new Map();
|
|
367
|
+
for (const [id, admission] of this.callAdmission) {
|
|
368
|
+
snapshots.set(id, [admission.snapshot()]);
|
|
369
|
+
}
|
|
370
|
+
for (const registry of this.personalRegistries.values()) {
|
|
371
|
+
for (const [id, admission] of registry.callAdmission) {
|
|
372
|
+
snapshots.get(id)?.push(admission.snapshot());
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return Object.fromEntries([...snapshots].map(([id, values]) => [
|
|
376
|
+
id, aggregateCallAdmissionSnapshots(values),
|
|
335
377
|
]));
|
|
336
378
|
}
|
|
337
379
|
/**
|
|
@@ -398,8 +440,54 @@ export class Registry {
|
|
|
398
440
|
}
|
|
399
441
|
/** Reject queued/future downstream admission; active permits release safely. */
|
|
400
442
|
closeCallAdmission() {
|
|
443
|
+
this.callAdmissionClosed = true;
|
|
401
444
|
for (const admission of this.callAdmission.values())
|
|
402
445
|
admission.close();
|
|
446
|
+
for (const registry of this.personalRegistries.values())
|
|
447
|
+
registry.closeCallAdmission();
|
|
448
|
+
}
|
|
449
|
+
/** Reserve capacity and write one ASCII paging envelope in this runtime. */
|
|
450
|
+
async stashResult(key, value, ttlSeconds, prefix = "results:") {
|
|
451
|
+
const maxBytes = this.opts.results?.maxStashBytes ?? 8 * 1024 * 1024;
|
|
452
|
+
const maxEntries = this.opts.results?.maxStashEntries ?? 64;
|
|
453
|
+
// The paging envelope is ASCII, so its string length is its stored byte count.
|
|
454
|
+
const bytes = value.length;
|
|
455
|
+
if (bytes > maxBytes || maxEntries === 0)
|
|
456
|
+
return false;
|
|
457
|
+
const now = Date.now();
|
|
458
|
+
for (const [oldKey, entry] of this.resultStash) {
|
|
459
|
+
if (entry.busy || entry.expiresAt > now)
|
|
460
|
+
continue;
|
|
461
|
+
entry.busy = true;
|
|
462
|
+
try {
|
|
463
|
+
// TTL alone cannot reclaim a lazy backend. Keep the charge until deletion
|
|
464
|
+
// succeeds, including writes which persisted before throwing.
|
|
465
|
+
await this.opts.storage.delete(oldKey);
|
|
466
|
+
this.resultStash.delete(oldKey);
|
|
467
|
+
this.resultStashBytes -= entry.bytes;
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
entry.busy = false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
|
|
474
|
+
return false;
|
|
475
|
+
const fullKey = prefix + key;
|
|
476
|
+
const entry = { bytes, expiresAt: Infinity, busy: true };
|
|
477
|
+
this.resultStash.set(fullKey, entry);
|
|
478
|
+
this.resultStashBytes += bytes;
|
|
479
|
+
try {
|
|
480
|
+
await this.opts.storage.set(fullKey, value, { ttlSeconds });
|
|
481
|
+
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
entry.expiresAt = 0;
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
entry.busy = false;
|
|
490
|
+
}
|
|
403
491
|
}
|
|
404
492
|
/**
|
|
405
493
|
* Storage namespaced to the meta-tool result store (`results:` prefix), kept
|
|
@@ -1078,12 +1166,27 @@ class ScopedRegistryView {
|
|
|
1078
1166
|
const connector = this.getConnector(parsed.connectorId);
|
|
1079
1167
|
return connector ? { connector, toolName: parsed.toolName } : null;
|
|
1080
1168
|
}
|
|
1081
|
-
getTools(...args) {
|
|
1169
|
+
async getTools(...args) {
|
|
1082
1170
|
const registry = this.registryFor(args[0]);
|
|
1083
1171
|
if (!registry) {
|
|
1084
|
-
|
|
1172
|
+
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1173
|
+
}
|
|
1174
|
+
const tools = await registry.getTools(...args);
|
|
1175
|
+
const granted = this.scope.toolAccess?.get(args[0]);
|
|
1176
|
+
if (!granted)
|
|
1177
|
+
return tools;
|
|
1178
|
+
// Every consumer — search, describe, call_tool, and a program's
|
|
1179
|
+
// connecta.call — resolves through this list, so an ungranted tool is
|
|
1180
|
+
// indistinguishable from one the connector never had.
|
|
1181
|
+
const visible = tools.filter((tool) => granted.has(tool.name));
|
|
1182
|
+
if (visible.length < granted.size) {
|
|
1183
|
+
const present = new Set(visible.map((tool) => tool.name));
|
|
1184
|
+
for (const name of granted) {
|
|
1185
|
+
if (!present.has(name))
|
|
1186
|
+
this.root.noteAbsentGrant(args[0], name);
|
|
1187
|
+
}
|
|
1085
1188
|
}
|
|
1086
|
-
return
|
|
1189
|
+
return visible;
|
|
1087
1190
|
}
|
|
1088
1191
|
contextFor(...args) {
|
|
1089
1192
|
const registry = this.registryFor(args[0]);
|
|
@@ -1092,10 +1195,14 @@ class ScopedRegistryView {
|
|
|
1092
1195
|
return registry.contextFor(...args);
|
|
1093
1196
|
}
|
|
1094
1197
|
admitCall(...args) {
|
|
1095
|
-
|
|
1198
|
+
const registry = this.registryFor(args[0]);
|
|
1199
|
+
if (!registry) {
|
|
1096
1200
|
return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
|
|
1097
1201
|
}
|
|
1098
|
-
return
|
|
1202
|
+
return registry.admitCall(...args);
|
|
1203
|
+
}
|
|
1204
|
+
stashResult(key, value, ttlSeconds) {
|
|
1205
|
+
return this.root.stashResult(key, value, ttlSeconds, this.scope.subjectKey ? `subject:${this.scope.subjectKey}:` : "results:");
|
|
1099
1206
|
}
|
|
1100
1207
|
resultsStorage() {
|
|
1101
1208
|
return this.scope.subjectKey
|
|
@@ -81,6 +81,7 @@ async function handleCredentialRequest(context, connectorId, action) {
|
|
|
81
81
|
validateAuthPermissions(authz, opts.registry);
|
|
82
82
|
registry = opts.registry.scoped({
|
|
83
83
|
connectorIds: authz.connectorIds,
|
|
84
|
+
...(authz.toolAccess ? { toolAccess: authz.toolAccess } : {}),
|
|
84
85
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
85
86
|
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
86
87
|
});
|
package/dist/routes/mcp.d.ts
CHANGED
|
@@ -4,4 +4,7 @@ export declare const MCP_CORS_HEADERS: {
|
|
|
4
4
|
"Access-Control-Allow-Methods": string;
|
|
5
5
|
"Access-Control-Allow-Headers": string;
|
|
6
6
|
};
|
|
7
|
-
export declare function createMcpRoute(opts: ServerOptions):
|
|
7
|
+
export declare function createMcpRoute(opts: ServerOptions): {
|
|
8
|
+
handle(context: RouteContext): Promise<Response | null>;
|
|
9
|
+
rejectOrigin(request: Request): Response | null;
|
|
10
|
+
};
|