@zackbart/connecta 0.28.1 → 0.29.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 +788 -5
- package/README.md +62 -42
- package/bin/connecta.mjs +110 -139
- package/bin/meta-tool-names.d.mts +2 -0
- package/bin/meta-tool-names.mjs +9 -0
- package/bin/version.mjs +3 -0
- package/dist/access-tokens.js +53 -63
- package/dist/activity-request.js +8 -0
- package/dist/activity.d.ts +76 -42
- package/dist/activity.js +110 -42
- package/dist/auth/clerk-sdk/client.d.ts +65 -0
- package/dist/auth/clerk-sdk/client.js +5 -0
- package/dist/auth/clerk-sdk/generated.js +1845 -0
- package/dist/auth/clerk-transport.js +19 -0
- package/dist/auth/clerk.d.ts +17 -7
- package/dist/auth/clerk.js +189 -107
- package/dist/auth/cloudflare-access.js +25 -26
- package/dist/auth/downstream-client-metadata.d.ts +29 -0
- package/dist/auth/downstream-client-metadata.js +133 -0
- package/dist/auth/downstream-oauth.js +1584 -1580
- package/dist/auth/downstream-revocation.js +106 -0
- package/dist/auth/oauth-token-response.js +231 -0
- package/dist/auth/oauth-v2-migration.js +116 -0
- package/dist/auth/static-oauth.js +93 -71
- package/dist/auth-elicitation.js +283 -0
- package/dist/branding.d.ts +8 -16
- package/dist/branding.js +26 -59
- package/dist/byte-read-response.js +39 -0
- package/dist/call-admission.js +12 -16
- package/dist/catalog-cache.js +739 -0
- package/dist/catalog-drift.d.ts +50 -15
- package/dist/catalog-drift.js +365 -128
- package/dist/catalog-fingerprint.js +2 -4
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-limits.js +2 -2
- package/dist/catalog-service.js +368 -224
- package/dist/catalog.js +43 -84
- package/dist/concurrency.js +3 -4
- package/dist/config-defaults.d.ts +39 -0
- package/dist/config-defaults.js +46 -0
- package/dist/config-schema.d.ts +181 -0
- package/dist/config-schema.js +435 -0
- package/dist/config-value-sources.js +102 -0
- package/dist/config.d.ts +631 -0
- package/dist/config.js +503 -0
- package/dist/connector-access.d.ts +1 -0
- package/dist/connector-access.js +13 -15
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +15 -6
- package/dist/connector-scope.js +1 -1
- package/dist/connectors/api-connector.d.ts +59 -7
- package/dist/connectors/api-connector.js +106 -19
- package/dist/connectors/api.js +4 -1
- package/dist/connectors/guarded-fetch.d.ts +144 -0
- package/dist/connectors/guarded-fetch.js +84 -35
- package/dist/connectors/negotiation-cache.js +46 -0
- package/dist/connectors/option-shapes.js +27 -0
- package/dist/connectors/remote-mcp.d.ts +30 -4
- package/dist/connectors/remote-mcp.js +1774 -320
- package/dist/connectors/resource-uri.js +335 -0
- package/dist/credential-contract.d.ts +10 -0
- package/dist/credential-rules.js +1 -3
- package/dist/credential-url.js +108 -0
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +27 -18
- package/dist/d1.d.ts +130 -0
- package/dist/d1.js +290 -0
- package/dist/describe-config.d.ts +165 -0
- package/dist/describe-config.js +315 -0
- package/dist/described.js +106 -0
- package/dist/downstream-input-context.js +119 -0
- package/dist/downstream-input.js +323 -0
- package/dist/errors.d.ts +97 -20
- package/dist/errors.js +158 -80
- package/dist/execute.js +453 -305
- package/dist/executor-admission.d.ts +1 -0
- package/dist/executor-admission.js +11 -15
- package/dist/executor-contract.d.ts +20 -0
- package/dist/executor-contract.js +48 -0
- package/dist/executor-result.js +24 -9
- package/dist/executors/quickjs-child.js +3 -4
- package/dist/executors/quickjs-runtime.js +147 -59
- package/dist/executors/quickjs.js +29 -35
- package/dist/favicon.js +1 -2
- package/dist/fix-prompt.js +1 -3
- package/dist/guest-runtime.js +188 -0
- package/dist/guest-types.d.ts +176 -0
- package/dist/guest-types.js +1 -0
- package/dist/html-security.js +24 -0
- package/dist/identity.js +1 -3
- package/dist/inbound-credential.js +52 -0
- package/dist/index.d.ts +24 -256
- package/dist/index.js +65 -478
- package/dist/invocation-auth.js +69 -0
- package/dist/invocation.js +230 -73
- package/dist/json-schema.d.ts +1 -1
- package/dist/mcp-client-context.js +35 -0
- package/dist/mcp-result.js +11 -7
- package/dist/meta-output.js +76 -0
- package/dist/meta-tool-names.d.ts +2 -0
- package/dist/meta-tool-names.js +3 -0
- package/dist/meta-tools.js +285 -209
- package/dist/module-contracts.d.ts +8 -23
- package/dist/node.d.ts +0 -2
- package/dist/node.js +2 -2
- package/dist/oauth-callback-outcome.js +1 -3
- package/dist/oauth-handoff.js +117 -0
- package/dist/oauth-sealing.js +20 -0
- package/dist/operator-record.d.ts +137 -0
- package/dist/operator-record.js +420 -0
- package/dist/operator-ui/assets.js +31 -0
- package/dist/operator-ui/contract.d.ts +72 -0
- package/dist/operator-ui/contract.js +1 -0
- package/dist/operator-ui/fix-prompts.d.ts +18 -0
- package/dist/operator-ui/generated.js +5 -4
- package/dist/operator-ui/model.d.ts +12 -87
- package/dist/operator-ui/model.js +2 -9
- package/dist/operator-ui/setup-commands.js +1 -3
- package/dist/operator-ui/view.d.ts +323 -0
- package/dist/operator-ui/view.js +62 -189
- package/dist/page-styles.js +2 -3
- package/dist/program-source.js +14 -2
- package/dist/{exempt-writes.js → program-writes.js} +55 -18
- package/dist/provider.d.ts +142 -0
- package/dist/provider.js +187 -0
- package/dist/providers/_shared/google/result-size.js +57 -0
- package/dist/providers/_shared/google/service-account.d.ts +58 -0
- package/dist/providers/_shared/google/service-account.js +500 -0
- package/dist/providers/_shared/google/workspace.d.ts +276 -0
- package/dist/providers/_shared/google/workspace.js +518 -0
- package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
- package/dist/providers/basecamp/index.js +997 -0
- package/dist/providers/basecamp/skill.generated.js +9 -0
- package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
- package/dist/providers/{breeze.js → breeze/index.js} +184 -140
- package/dist/providers/breeze/skill.generated.js +9 -0
- package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
- package/dist/providers/{ccb.js → ccb/index.js} +271 -116
- package/dist/providers/ccb/skill.generated.js +9 -0
- package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
- package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
- package/dist/providers/cloudflare/skill.generated.js +10 -0
- package/dist/providers/docs/index.d.ts +54 -0
- package/dist/providers/docs/index.js +1237 -0
- package/dist/providers/docs/skill.generated.js +9 -0
- package/dist/providers/drive/index.d.ts +53 -0
- package/dist/providers/drive/index.js +1523 -0
- package/dist/providers/drive/skill.generated.js +9 -0
- package/dist/providers/forms/index.d.ts +54 -0
- package/dist/providers/forms/index.js +1217 -0
- package/dist/providers/forms/skill.generated.js +11 -0
- package/dist/providers/github/auth.d.ts +32 -0
- package/dist/providers/github/auth.js +361 -0
- package/dist/providers/github/hosted.js +218 -0
- package/dist/providers/github/index.d.ts +13 -0
- package/dist/providers/github/index.js +140 -0
- package/dist/providers/github/key.js +100 -0
- package/dist/providers/github/rest.js +321 -0
- package/dist/providers/github/scope.d.ts +29 -0
- package/dist/providers/github/scope.js +124 -0
- package/dist/providers/github/skill.generated.js +8 -0
- package/dist/providers/gmail/index.d.ts +51 -0
- package/dist/providers/gmail/index.js +1436 -0
- package/dist/providers/gmail/skill.generated.js +9 -0
- package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
- package/dist/providers/linear/index.js +352 -0
- package/dist/providers/linear/skill.generated.js +8 -0
- package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
- package/dist/providers/mixpanel/index.js +416 -0
- package/dist/providers/mixpanel/skill.generated.js +9 -0
- package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
- package/dist/providers/{notion.js → notion/index.js} +226 -239
- package/dist/providers/notion/skill.generated.js +10 -0
- package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
- package/dist/providers/{overflow.js → overflow/index.js} +74 -111
- package/dist/providers/overflow/skill.generated.js +8 -0
- package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
- package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
- package/dist/providers/planning-center/skill.generated.js +10 -0
- package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
- package/dist/providers/revenuecat/index.js +557 -0
- package/dist/providers/revenuecat/skill.generated.js +11 -0
- package/dist/providers/sheets/index.d.ts +54 -0
- package/dist/providers/sheets/index.js +1237 -0
- package/dist/providers/sheets/skill.generated.js +10 -0
- package/dist/providers/slides/index.d.ts +53 -0
- package/dist/providers/slides/index.js +2645 -0
- package/dist/providers/slides/skill.generated.js +9 -0
- package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
- package/dist/providers/stripe/index.js +266 -0
- package/dist/providers/stripe/skill.generated.js +11 -0
- package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
- package/dist/providers/{tithely.js → tithely/index.js} +60 -91
- package/dist/providers/tithely/skill.generated.js +8 -0
- package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
- package/dist/providers/vercel/index.js +1038 -0
- package/dist/providers/vercel/mcp-ownership.js +212 -0
- package/dist/providers/vercel/skill.generated.js +11 -0
- package/dist/registry.d.ts +108 -171
- package/dist/registry.js +513 -925
- package/dist/request-state.js +21 -0
- package/dist/resource-read.js +100 -0
- package/dist/result-shapes.js +17 -49
- package/dist/retired-toolkits.js +1 -1
- package/dist/routes/access-tokens.js +3 -6
- package/dist/routes/activity-context.js +21 -0
- package/dist/routes/activity-disclosure.js +40 -0
- package/dist/routes/activity.js +59 -21
- package/dist/routes/actor-labels.js +3 -50
- package/dist/routes/connect.js +221 -0
- package/dist/routes/credentials.js +15 -27
- package/dist/routes/mcp.js +356 -130
- package/dist/routes/oauth-client-metadata.js +15 -0
- package/dist/routes/oauth-management.js +45 -107
- package/dist/routes/oauth.js +110 -57
- package/dist/routes/operator.js +3 -3
- package/dist/routes/shared.d.ts +25 -64
- package/dist/routes/shared.js +160 -80
- package/dist/routes/skills.js +30 -0
- package/dist/routes/ui-config.js +225 -0
- package/dist/routes/ui-data.js +40 -35
- package/dist/routes/ui.js +66 -56
- package/dist/runtime/admission.js +4 -2
- package/dist/runtime/call-admission.js +3 -1
- package/dist/runtime/connector-scope.js +1 -4
- package/dist/runtime/run.js +10 -39
- package/dist/runtime/services.js +12 -117
- package/dist/runtime/shared-read.js +1 -3
- package/dist/runtime/storage.js +8 -1
- package/dist/sent-secrets.d.ts +52 -0
- package/dist/sent-secrets.js +474 -0
- package/dist/server.js +28 -71
- package/dist/skills.js +406 -174
- package/dist/sqlite.d.ts +48 -0
- package/dist/sqlite.js +166 -0
- package/dist/storage/keys.js +428 -0
- package/dist/storage/memory.js +7 -0
- package/dist/storage/sql.d.ts +66 -0
- package/dist/storage/sql.js +534 -0
- package/dist/timeout.js +4 -2
- package/dist/tool-name.js +17 -0
- package/dist/tool-safety.d.ts +9 -35
- package/dist/tool-safety.js +20 -40
- package/dist/types.d.ts +320 -54
- package/dist/types.js +0 -1
- package/dist/ui.d.ts +7 -19
- package/dist/ui.js +76 -90
- package/dist/url-safety.js +3 -6
- package/dist/usage-guide.js +137 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.js +309 -47
- package/dist/version.d.ts +1 -7
- package/dist/version.js +3 -7
- package/dist/worker.d.ts +11 -0
- package/dist/worker.js +434 -0
- package/documentation/architecture.md +664 -231
- package/documentation/auth.md +936 -546
- package/documentation/code-mode.md +501 -312
- package/documentation/meta-tools.md +449 -200
- package/documentation/operator-tests.md +93 -0
- package/documentation/provider-migration-0.29.md +60 -0
- package/examples/worker/AGENTS.md +34 -12
- package/examples/worker/README.md +286 -255
- package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
- package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
- package/examples/worker/src/connecta.config.ts +151 -0
- package/examples/worker/src/index.ts +14 -197
- package/examples/worker/wrangler.jsonc +15 -35
- package/package.json +120 -54
- package/templates/node/.dockerignore +1 -2
- package/templates/node/.env.example +20 -21
- package/templates/node/AGENTS.md +21 -17
- package/templates/node/Dockerfile +3 -5
- package/templates/node/README.md +133 -58
- package/templates/node/docker-compose.yml +11 -12
- package/templates/node/package.json +4 -2
- package/templates/node/src/connecta.config.ts +119 -0
- package/templates/node/src/index.ts +9 -154
- package/templates/node/src/provision-token.ts +17 -0
- package/AGENTS.md +0 -170
- package/dist/artifacts/connector.d.ts +0 -58
- package/dist/artifacts/connector.js +0 -730
- package/dist/artifacts/document.js +0 -86
- package/dist/artifacts/guide.js +0 -140
- package/dist/artifacts/html-scan.js +0 -329
- package/dist/artifacts/json.js +0 -78
- package/dist/artifacts/kv-store.d.ts +0 -20
- package/dist/artifacts/kv-store.js +0 -140
- package/dist/artifacts/markdown.js +0 -478
- package/dist/artifacts/module.d.ts +0 -37
- package/dist/artifacts/module.js +0 -66
- package/dist/artifacts/operations.d.ts +0 -197
- package/dist/artifacts/operations.js +0 -1002
- package/dist/artifacts/refresh.d.ts +0 -55
- package/dist/artifacts/refresh.js +0 -236
- package/dist/artifacts/routes.js +0 -206
- package/dist/artifacts/types.d.ts +0 -214
- package/dist/artifacts/types.js +0 -22
- package/dist/artifacts/validate.d.ts +0 -62
- package/dist/artifacts/validate.js +0 -651
- package/dist/artifacts.d.ts +0 -6
- package/dist/artifacts.js +0 -9
- package/dist/auth/bearer.d.ts +0 -11
- package/dist/auth/bearer.js +0 -50
- package/dist/providers/basecamp.js +0 -445
- package/dist/providers/linear.js +0 -217
- package/dist/providers/mixpanel.js +0 -247
- package/dist/providers/revenuecat.js +0 -317
- package/dist/providers/stripe.js +0 -253
- package/dist/providers/vercel.js +0 -1253
- package/dist/storage/file.d.ts +0 -15
- package/dist/storage/file.js +0 -455
- package/ethos.md +0 -93
- package/examples/worker/src/cloudflare-kv.ts +0 -43
- package/examples/worker/src/d1-activity-row.ts +0 -155
- package/examples/worker/src/d1-activity.ts +0 -147
- package/examples/worker/src/d1-storage.ts +0 -138
- package/examples/worker/src/r2-artifact-blobs.ts +0 -29
- package/templates/node/src/file-activity.ts +0 -182
package/dist/registry.js
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { markCatalogFreshness, carryCatalogFreshness } from "./catalog-freshness.js";
|
|
2
|
+
import { activityRequest } from "./activity-request.js";
|
|
3
|
+
import { attachCatalogCache, catalogExpiry, customCatalogFallback, storeCustomCatalogFallback, catalogFetchedAt, invalidateCatalogCache, observeUncachedCatalogRefresh, } from "./catalog-cache.js";
|
|
4
|
+
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
5
|
+
import { assertStaticToolNames, hasControlCharacters } from "./tool-name.js";
|
|
6
|
+
import { Clock, Duration, Effect, Random } from "effect";
|
|
7
|
+
import { storedCredentialShape } from "./credential-rules.js";
|
|
8
|
+
import { ConnectorCallError } from "./errors.js";
|
|
9
|
+
import { boundedStatus, failureRecord, failureStatus, logFailure, ownStatus } from "./operator-record.js";
|
|
4
10
|
import { ConnectorCallAdmissionController, aggregateCallAdmissionSnapshots, } from "./call-admission.js";
|
|
5
|
-
import { boundedCatalogDrift } from "./catalog-drift.js";
|
|
6
|
-
import {
|
|
7
|
-
import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES, } from "./catalog-limits.js";
|
|
11
|
+
import { boundedCatalogDrift, catalogReviewOf, classifyCatalog, observeReviewedDrift, observedCatalogDrift, } from "./catalog-drift.js";
|
|
12
|
+
import { MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "./catalog-limits.js";
|
|
8
13
|
import { ObservedOutputSchemas } from "./result-shapes.js";
|
|
9
|
-
import {
|
|
14
|
+
import { redactCatalog, sentSecretsFor, sentSecretsForRequest, trackCredentialReads } from "./sent-secrets.js";
|
|
15
|
+
import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary } from "./skills.js";
|
|
10
16
|
import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
|
|
11
17
|
import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
|
|
12
18
|
import { attachCaller } from "./connector-caller.js";
|
|
13
|
-
import {
|
|
14
|
-
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
19
|
+
import { runEdge } from "./runtime/run.js";
|
|
15
20
|
import { SharedRead } from "./runtime/shared-read.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
21
|
+
import { runOnPartition, storageCompareAndSet, storageDelete, storageGet, storageSet } from "./runtime/storage.js";
|
|
22
|
+
import { jsonCodec, OAUTH_HANDOFF_TTL_SECONDS, oauthHandoffKeys, resultKeys, scopes, stashLedgerKeys, } from "./storage/keys.js";
|
|
23
|
+
import { classifyTool } from "./tool-safety.js";
|
|
20
24
|
const ID_RE = /^[a-z0-9_-]+$/;
|
|
21
|
-
const
|
|
22
|
-
const DEFAULT_STALE_SECONDS = 3600;
|
|
23
|
-
const CATALOG_CHUNK_TTL_GRACE_SECONDS = 300;
|
|
24
|
-
const DEFAULT_MAX_RESULT_BYTES = 24_000;
|
|
25
|
+
const DEFAULT_MAX_RESULT_BYTES = CONFIG_DEFAULTS.calls.maxResultBytes;
|
|
25
26
|
const encoder = new TextEncoder();
|
|
26
27
|
/**
|
|
27
28
|
* Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
|
|
@@ -50,7 +51,7 @@ export const MIN_MAX_RESULT_BYTES = 1;
|
|
|
50
51
|
/**
|
|
51
52
|
* The one definition of a usable `maxResultBytes`: a finite whole number of at
|
|
52
53
|
* least {@link MIN_MAX_RESULT_BYTES} bytes. Shared by all three intake points
|
|
53
|
-
* — `calls.maxResultBytes`, the per-connector override, and `
|
|
54
|
+
* — `calls.maxResultBytes`, the per-connector override, and `connecta.result`'s
|
|
54
55
|
* `maxBytes` argument — so a value that is valid at one is valid at all.
|
|
55
56
|
*
|
|
56
57
|
* Everything else is rejected rather than coerced, because each rejected shape
|
|
@@ -64,105 +65,138 @@ export function isValidMaxResultBytes(value) {
|
|
|
64
65
|
}
|
|
65
66
|
/**
|
|
66
67
|
* Resolve a configured cap against the value it inherits, dropping anything
|
|
67
|
-
* `isValidMaxResultBytes` rejects.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* a broken cap.
|
|
68
|
+
* `isValidMaxResultBytes` rejects. Construction already refuses an unusable
|
|
69
|
+
* cap (`Registry.assertResultCaps`); the resolution stays total so no call
|
|
70
|
+
* site has to cope with a broken one.
|
|
71
71
|
*/
|
|
72
72
|
export function resolveMaxResultBytes(value, inherited) {
|
|
73
|
-
return value !== undefined && isValidMaxResultBytes(value)
|
|
74
|
-
? value
|
|
75
|
-
: inherited;
|
|
73
|
+
return value !== undefined && isValidMaxResultBytes(value) ? value : inherited;
|
|
76
74
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Run `operation` over every chunk index or chunk with the fixed chunk I/O
|
|
91
|
-
* bound, settling each: one failure neither interrupts its siblings nor
|
|
92
|
-
* leaves their storage calls running behind the caller. Results keep input
|
|
93
|
-
* order, so the first failure by index is the one a caller reports.
|
|
94
|
-
*/
|
|
95
|
-
function forEachChunk(items, operation) {
|
|
96
|
-
return Effect.forEach(items, (item, index) => Effect.result(operation(item, index)), { concurrency: CATALOG_CHUNK_IO_CONCURRENCY });
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* A persisted-catalog mutation that logs its storage failure rather than
|
|
100
|
-
* failing, so its turn in the mutation queue always ends.
|
|
101
|
-
*/
|
|
102
|
-
function warnOnFailure(mutation, failed) {
|
|
103
|
-
return Effect.catch(mutation, (err) => LoggerService.use((logger) => Effect.sync(() => logger.warn(`[connecta] ${failed}: ${msg(err)}`))));
|
|
75
|
+
/** Freeze registry-owned facts so review digest memoization remains valid. */
|
|
76
|
+
function frozenFacts(tools) {
|
|
77
|
+
const pending = [tools];
|
|
78
|
+
while (pending.length > 0) {
|
|
79
|
+
const value = pending.pop();
|
|
80
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value))
|
|
81
|
+
continue;
|
|
82
|
+
Object.freeze(value);
|
|
83
|
+
for (const item of Object.values(value))
|
|
84
|
+
pending.push(item);
|
|
85
|
+
}
|
|
86
|
+
return tools;
|
|
104
87
|
}
|
|
105
88
|
function namespaced(storage, prefix) {
|
|
106
89
|
return {
|
|
107
90
|
get: (k) => storage.get(prefix + k),
|
|
108
91
|
set: (k, v, o) => storage.set(prefix + k, v, o),
|
|
109
92
|
delete: (k) => storage.delete(prefix + k),
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
|
|
113
|
-
}
|
|
114
|
-
: {}),
|
|
115
|
-
...(storage.compareAndSet
|
|
116
|
-
? {
|
|
117
|
-
compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
|
|
118
|
-
}
|
|
119
|
-
: {}),
|
|
93
|
+
list: async (keyPrefix) => (await storage.list(prefix + keyPrefix)).map((key) => key.slice(prefix.length)),
|
|
94
|
+
compareAndSet: (k, expected, next, o) => storage.compareAndSet(prefix + k, expected, next, o),
|
|
120
95
|
};
|
|
121
96
|
}
|
|
122
97
|
const MAX_PERSONAL_REGISTRIES = 1_024;
|
|
123
98
|
const MAX_ABSENT_GRANT_WARNINGS = 1_024;
|
|
124
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Compare-and-set attempts one stash makes on the ledger. A lost swap means
|
|
101
|
+
* another stash changed the ledger first, never that capacity ran out, so
|
|
102
|
+
* the loser re-reads and plans again after a jittered, growing pause: of 64
|
|
103
|
+
* simultaneous claims, each one either books a charge or meets a full ledger.
|
|
104
|
+
* Exhausting the attempts takes seconds of sustained contention on the one
|
|
105
|
+
* ledger record, and then the caller keeps its preview.
|
|
106
|
+
*/
|
|
107
|
+
const STASH_LEDGER_ATTEMPTS = 32;
|
|
108
|
+
/** First backoff window after a lost swap; it doubles up to the cap. */
|
|
109
|
+
const STASH_LEDGER_BACKOFF_MS = 4;
|
|
110
|
+
const STASH_LEDGER_BACKOFF_CAP_MS = 250;
|
|
111
|
+
/**
|
|
112
|
+
* Slack past a stash's deadline before its charge leaves the ledger, and the
|
|
113
|
+
* longest one chunk write may take. A chunk's TTL is what remains of the
|
|
114
|
+
* deadline on the clock read before its write, and the store starts that TTL
|
|
115
|
+
* no later than the write returns, so the chunk expires within the write's
|
|
116
|
+
* duration of the deadline. A write slower than this fails the stash.
|
|
117
|
+
*/
|
|
118
|
+
const STASH_LEDGER_GRACE_MS = 30_000;
|
|
119
|
+
/**
|
|
120
|
+
* Live charges in a stored ledger. Anything malformed reads as empty: the
|
|
121
|
+
* ledger bounds an advisory cache, and the next successful swap replaces it.
|
|
122
|
+
*/
|
|
123
|
+
function liveStashCharges(raw, now) {
|
|
124
|
+
if (raw === null)
|
|
125
|
+
return [];
|
|
126
|
+
let parsed;
|
|
127
|
+
try {
|
|
128
|
+
parsed = jsonCodec.decode(raw);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
const entries = parsed?.entries;
|
|
134
|
+
if (!Array.isArray(entries))
|
|
135
|
+
return [];
|
|
136
|
+
return entries.filter((entry) => Array.isArray(entry) &&
|
|
137
|
+
entry.length === 3 &&
|
|
138
|
+
typeof entry[0] === "string" &&
|
|
139
|
+
Number.isSafeInteger(entry[1]) &&
|
|
140
|
+
entry[1] >= 0 &&
|
|
141
|
+
typeof entry[2] === "number" &&
|
|
142
|
+
entry[2] > now);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Rewrite the stash ledger by compare-and-set. `plan` sees the live charges
|
|
146
|
+
* and the time it read them at, and answers its result with the entries to
|
|
147
|
+
* store, or without them when there is nothing to write. Only `plan` refuses:
|
|
148
|
+
* a lost swap backs off and plans again from a fresh read. Answers undefined
|
|
149
|
+
* once the attempts run out.
|
|
150
|
+
*/
|
|
151
|
+
function swapStashLedger(plan) {
|
|
152
|
+
return Effect.gen(function* () {
|
|
153
|
+
const ledger = stashLedgerKeys.ledger;
|
|
154
|
+
for (let attempt = 0; attempt < STASH_LEDGER_ATTEMPTS; attempt++) {
|
|
155
|
+
if (attempt > 0) {
|
|
156
|
+
const window = Math.min(STASH_LEDGER_BACKOFF_CAP_MS, STASH_LEDGER_BACKOFF_MS * 2 ** (attempt - 1));
|
|
157
|
+
yield* Effect.sleep(Duration.millis(Math.floor((yield* Random.next) * window) + 1));
|
|
158
|
+
}
|
|
159
|
+
const raw = yield* storageGet(ledger);
|
|
160
|
+
const now = yield* Clock.currentTimeMillis;
|
|
161
|
+
const planned = plan(liveStashCharges(raw, now), now);
|
|
162
|
+
if (planned.entries === undefined)
|
|
163
|
+
return planned.result;
|
|
164
|
+
if (yield* storageCompareAndSet(ledger, raw, jsonCodec.encode({ v: 1, entries: planned.entries }))) {
|
|
165
|
+
return planned.result;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
125
171
|
async function sha256Hex(value) {
|
|
126
172
|
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
|
|
127
|
-
return [...bytes]
|
|
128
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
129
|
-
.join("");
|
|
173
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
130
174
|
}
|
|
131
175
|
/**
|
|
132
|
-
* Holds the connector set, resolves addresses, and
|
|
133
|
-
*
|
|
176
|
+
* Holds the connector set, resolves addresses, and coalesces tool listings
|
|
177
|
+
* within one request. Remote MCP clients own persisted response caching. Connector failures are isolated: a broken
|
|
134
178
|
* connector surfaces status "error"; the rest keep working.
|
|
135
179
|
*/
|
|
136
180
|
export class Registry {
|
|
137
181
|
opts;
|
|
138
182
|
connectors = new Map();
|
|
139
183
|
callAdmission = new Map();
|
|
140
|
-
|
|
141
|
-
invalidated = new Set();
|
|
142
|
-
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
143
|
-
catalogGenerations = new Map();
|
|
144
|
-
// The last queued mutation per connector, as the turn its own request
|
|
145
|
-
// completes once its storage work is done; see enqueueCatalogMutation.
|
|
146
|
-
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
147
|
-
catalogMutations = new Map();
|
|
148
|
-
/** Same-request cold loads share one read without retaining the request.
|
|
149
|
-
* See documentation/architecture.md#the-two-lifetimes. */
|
|
184
|
+
/** Coalesce listings only within one request and authorization partition. */
|
|
150
185
|
requestCatalogLoads = new WeakMap();
|
|
151
|
-
|
|
152
|
-
catalogRefreshes = new Map();
|
|
186
|
+
catalogObservedAt = new Map();
|
|
153
187
|
/** Last payload-free agent catalog access in this runtime. */
|
|
154
188
|
catalogAccess = new Map();
|
|
155
|
-
/**
|
|
156
|
-
|
|
189
|
+
/** Count-only intake findings, including catalogs loaded from storage. */
|
|
190
|
+
droppedToolNames = new Map();
|
|
191
|
+
/**
|
|
192
|
+
* Schema digest verification per reviewed facts array. Only the registry's
|
|
193
|
+
* own deep-frozen arrays are keys, so an entry stays true while they live.
|
|
194
|
+
*/
|
|
195
|
+
verifiedFacts = new WeakMap();
|
|
157
196
|
observedOutputSchemas;
|
|
158
|
-
ttlMs;
|
|
159
|
-
staleMs;
|
|
160
|
-
persistToolCatalog;
|
|
161
197
|
/** Result-size guard cap threaded to the meta-tools. */
|
|
162
198
|
maxResultBytes;
|
|
163
|
-
|
|
164
|
-
resultStash = new Map();
|
|
165
|
-
resultStashBytes = 0;
|
|
199
|
+
classification;
|
|
166
200
|
configuredConnectors;
|
|
167
201
|
personalRegistries = new Map();
|
|
168
202
|
oauthPartition = {};
|
|
@@ -173,13 +207,9 @@ export class Registry {
|
|
|
173
207
|
this.opts = opts;
|
|
174
208
|
this.configuredConnectors = [...connectors];
|
|
175
209
|
this.observedOutputSchemas = new ObservedOutputSchemas();
|
|
176
|
-
this.ttlMs =
|
|
177
|
-
(opts.toolCacheTtlSeconds ?? DEFAULT_TTL_SECONDS) * 1000;
|
|
178
|
-
this.staleMs =
|
|
179
|
-
(opts.toolCatalogStaleSeconds ?? DEFAULT_STALE_SECONDS) * 1000;
|
|
180
|
-
this.persistToolCatalog = opts.persistToolCatalog ?? true;
|
|
181
210
|
this.maxResultBytes = resolveMaxResultBytes(opts.maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
|
|
182
211
|
for (const c of connectors) {
|
|
212
|
+
assertStaticToolNames(c.staticTools ?? [], `Connector(${JSON.stringify(c.id)}).staticTools`);
|
|
183
213
|
if ("handleRequest" in c) {
|
|
184
214
|
throw new Error(`Connector "${c.id}" declares removed handleRequest. ` +
|
|
185
215
|
"Move custom HTTP routes into the deployment's fetch handler.");
|
|
@@ -190,29 +220,46 @@ export class Registry {
|
|
|
190
220
|
if (this.connectors.has(c.id)) {
|
|
191
221
|
throw new Error(`Duplicate connector id "${c.id}"`);
|
|
192
222
|
}
|
|
193
|
-
if (c.authScope !== undefined &&
|
|
194
|
-
c.authScope !== "shared" &&
|
|
195
|
-
c.authScope !== "personal") {
|
|
223
|
+
if (c.authScope !== undefined && c.authScope !== "shared" && c.authScope !== "personal") {
|
|
196
224
|
throw new Error(`Invalid authScope on connector "${c.id}": expected "shared" or "personal"`);
|
|
197
225
|
}
|
|
198
|
-
const configuredGuideSummary = typeof c.usageGuide === "object"
|
|
199
|
-
|
|
200
|
-
: undefined;
|
|
201
|
-
if (configuredGuideSummary !== undefined &&
|
|
202
|
-
configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
|
|
226
|
+
const configuredGuideSummary = typeof c.usageGuide === "object" ? normalizeGuideSummary(c.usageGuide.summary ?? "") : undefined;
|
|
227
|
+
if (configuredGuideSummary !== undefined && configuredGuideSummary.length > GUIDE_SUMMARY_LENGTH) {
|
|
203
228
|
throw new Error(`Connector "${c.id}" usageGuide.summary is ` +
|
|
204
229
|
`${configuredGuideSummary.length} characters after whitespace ` +
|
|
205
230
|
`normalization; the discovery bound is ${GUIDE_SUMMARY_LENGTH}. ` +
|
|
206
231
|
"Shorten it or omit it to derive one.");
|
|
207
232
|
}
|
|
233
|
+
// Validated here, before any request (INV-11), and read once per
|
|
234
|
+
// connector object.
|
|
235
|
+
if (catalogReviewOf(c) && c.staticTools) {
|
|
236
|
+
throw new Error(`Connector "${c.id}" declares both staticTools and a classification; ` + "annotate static tools directly.");
|
|
237
|
+
}
|
|
208
238
|
this.connectors.set(c.id, c);
|
|
209
239
|
if (c.callAdmission) {
|
|
210
240
|
this.callAdmission.set(c.id, new ConnectorCallAdmissionController(c.id, c.callAdmission));
|
|
211
241
|
}
|
|
212
242
|
}
|
|
243
|
+
this.classification = Object.assign(Object.create(null), Object.fromEntries(Object.entries(opts.classification ?? {}).map(([id, tools]) => [
|
|
244
|
+
id,
|
|
245
|
+
Object.freeze(Object.assign(Object.create(null), tools)),
|
|
246
|
+
])));
|
|
247
|
+
Object.freeze(this.classification);
|
|
248
|
+
for (const [id, overrides] of Object.entries(this.classification)) {
|
|
249
|
+
for (const [name, verdict] of Object.entries(overrides)) {
|
|
250
|
+
if (!name || (verdict !== "read" && verdict !== "write")) {
|
|
251
|
+
throw new Error(`ConnectaConfig.classification.${id}.${name}: expected "read" or "write"`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const connector = this.connectors.get(id);
|
|
255
|
+
if (!connector)
|
|
256
|
+
throw new Error(`ConnectaConfig.classification: unknown connector "${id}"`);
|
|
257
|
+
if (connector.staticTools)
|
|
258
|
+
this.validateClassification(id, connector.staticTools, overrides);
|
|
259
|
+
}
|
|
260
|
+
this.assertResultCaps(opts.maxResultBytes);
|
|
213
261
|
if (opts.constructionChecks !== false) {
|
|
214
262
|
this.checkConventions(opts.logger);
|
|
215
|
-
this.checkResultCaps(opts.logger, opts.maxResultBytes);
|
|
216
263
|
}
|
|
217
264
|
}
|
|
218
265
|
personalRegistry(principalKey) {
|
|
@@ -224,15 +271,9 @@ export class Registry {
|
|
|
224
271
|
}
|
|
225
272
|
if (this.personalRegistries.size >= MAX_PERSONAL_REGISTRIES) {
|
|
226
273
|
// Eviction must not reset a live rolling budget or orphan queued calls.
|
|
227
|
-
// Nor may it drop catalog work in flight: a replacement for the same
|
|
228
|
-
// principal starts with no generations and no mutation queue, so a
|
|
229
|
-
// refresh the evicted registry finishes later would persist a listing
|
|
230
|
-
// that a credential change on the replacement had just deleted.
|
|
231
274
|
// OAuth status/start work bypasses both gates, and accepted rotations
|
|
232
275
|
// may still be saving after their caller leaves. Preserve its partition.
|
|
233
|
-
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
|
|
234
|
-
candidate.catalogRefreshes.size === 0 &&
|
|
235
|
-
candidate.catalogMutations.size === 0 &&
|
|
276
|
+
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every((admission) => admission.isIdle()) &&
|
|
236
277
|
oauthPartitionIdle(candidate.oauthPartition));
|
|
237
278
|
if (!idle) {
|
|
238
279
|
throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
|
|
@@ -242,10 +283,15 @@ export class Registry {
|
|
|
242
283
|
}
|
|
243
284
|
const registry = new Registry(this.configuredConnectors.filter((connector) => connector.authScope === "personal"), {
|
|
244
285
|
...this.opts,
|
|
245
|
-
|
|
286
|
+
classification: Object.fromEntries(Object.entries(this.classification).filter(([id]) => this.connectors.get(id)?.authScope === "personal")),
|
|
287
|
+
storage: namespaced(this.opts.storage, scopes.principal(principalKey)),
|
|
246
288
|
credentialOwner: principalKey,
|
|
289
|
+
catalogStorage: this.opts.catalogStorage ?? this.opts.storage,
|
|
247
290
|
constructionChecks: false,
|
|
248
291
|
});
|
|
292
|
+
// Counts only, like the connector-wide reviewed drift observation. No
|
|
293
|
+
// principal, credentials, or catalog payload crosses this registry boundary.
|
|
294
|
+
registry.droppedToolNames = this.droppedToolNames;
|
|
249
295
|
if (this.callAdmissionClosed)
|
|
250
296
|
registry.closeCallAdmission();
|
|
251
297
|
this.personalRegistries.set(principalKey, registry);
|
|
@@ -253,9 +299,7 @@ export class Registry {
|
|
|
253
299
|
}
|
|
254
300
|
/** Build the only connector view an authenticated request receives. */
|
|
255
301
|
scoped(scope) {
|
|
256
|
-
const requested = scope.connectorIds === "all"
|
|
257
|
-
? new Set(this.connectors.keys())
|
|
258
|
-
: new Set(scope.connectorIds);
|
|
302
|
+
const requested = scope.connectorIds === "all" ? new Set(this.connectors.keys()) : new Set(scope.connectorIds);
|
|
259
303
|
for (const id of requested) {
|
|
260
304
|
if (!this.connectors.has(id)) {
|
|
261
305
|
throw new Error(`Identity access resolver returned unknown connector "${id}"`);
|
|
@@ -264,7 +308,7 @@ export class Registry {
|
|
|
264
308
|
return new ScopedRegistryView(this, requested, scope);
|
|
265
309
|
}
|
|
266
310
|
scopedStorage(subjectKey) {
|
|
267
|
-
return namespaced(this.opts.storage,
|
|
311
|
+
return namespaced(this.opts.storage, scopes.subject(subjectKey));
|
|
268
312
|
}
|
|
269
313
|
/**
|
|
270
314
|
* A granted `connector.tool` address the live catalog does not contain is
|
|
@@ -282,87 +326,70 @@ export class Registry {
|
|
|
282
326
|
if (oldest !== undefined)
|
|
283
327
|
this.warnedAbsentGrants.delete(oldest);
|
|
284
328
|
}
|
|
329
|
+
if (hasControlCharacters(toolName)) {
|
|
330
|
+
logFailure(this.opts.logger, "connectorAccess grant is unreachable", failureRecord({ connector: connectorId }));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
285
333
|
// Grant names are operator data but may carry any non-control character;
|
|
286
334
|
// quote them so a line terminator a log reader honours cannot forge a line.
|
|
287
335
|
const quoted = JSON.stringify(key).replace(/[\u2028\u2029]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16)}`);
|
|
288
336
|
this.opts.logger.warn(`connectorAccess grants ${quoted} but connector "${connectorId}" lists no such tool; the grant is unreachable`);
|
|
289
337
|
}
|
|
290
|
-
oauthHandoffKey(connectorId, stateHash) {
|
|
291
|
-
return `oauth-handoff:v1:${connectorId}:${stateHash}`;
|
|
292
|
-
}
|
|
293
338
|
async storeOAuthHandoff(connectorId, state, principalKey) {
|
|
294
|
-
const key =
|
|
339
|
+
const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
|
|
295
340
|
for (let attempt = 0; attempt < 32; attempt++) {
|
|
296
341
|
const existing = await this.opts.storage.get(key);
|
|
297
342
|
if (existing && existing !== principalKey) {
|
|
298
343
|
throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
|
|
299
344
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
await this.opts.storage.set(key, principalKey, expiry);
|
|
309
|
-
}
|
|
310
|
-
return;
|
|
345
|
+
// Bind ownership atomically, including renewal of the same owner's
|
|
346
|
+
// handoff. Two owners reading a miss must not overwrite each other.
|
|
347
|
+
if (await this.opts.storage.compareAndSet(key, existing, principalKey, {
|
|
348
|
+
ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS,
|
|
349
|
+
}))
|
|
350
|
+
return;
|
|
311
351
|
}
|
|
312
352
|
throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
|
|
313
353
|
}
|
|
314
354
|
async oauthCallbackView(connectorId, state) {
|
|
315
|
-
const connector = this.connectors.get(connectorId);
|
|
316
|
-
if (!connector)
|
|
317
|
-
return null;
|
|
318
|
-
if (connector.authScope !== "personal")
|
|
319
|
-
return { registry: this };
|
|
320
355
|
if (!state)
|
|
321
356
|
return null;
|
|
322
|
-
const principalKey = await this.opts.storage.get(
|
|
323
|
-
|
|
357
|
+
const principalKey = await this.opts.storage.get(oauthHandoffKeys.handoff(connectorId, await sha256Hex(state)));
|
|
358
|
+
const connector = this.connectors.get(connectorId);
|
|
359
|
+
if (!connector || !principalKey)
|
|
324
360
|
return null;
|
|
325
361
|
return {
|
|
326
|
-
registry:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
362
|
+
registry: connector.authScope === "personal"
|
|
363
|
+
? this.scoped({
|
|
364
|
+
connectorIds: [connectorId],
|
|
365
|
+
subjectKey: principalKey,
|
|
366
|
+
principalKey,
|
|
367
|
+
})
|
|
368
|
+
: this,
|
|
331
369
|
principalKey,
|
|
332
370
|
};
|
|
333
371
|
}
|
|
334
|
-
async
|
|
372
|
+
async consumeOAuthHandoff(connectorId, state, principalKey) {
|
|
335
373
|
if (!state)
|
|
336
|
-
return;
|
|
337
|
-
|
|
374
|
+
return false;
|
|
375
|
+
const key = oauthHandoffKeys.handoff(connectorId, await sha256Hex(state));
|
|
376
|
+
return this.opts.storage.compareAndSet(key, principalKey, null);
|
|
338
377
|
}
|
|
339
378
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* told which one is actually in force.
|
|
379
|
+
* Refuse an unusable result cap at construction, like every other
|
|
380
|
+
* structural mistake (INV-11). A rejected cap can't be honoured, and
|
|
381
|
+
* honouring it *approximately* is exactly the inversion issue #32 is about;
|
|
382
|
+
* falling back with a warning let a deployment boot on a value nobody
|
|
383
|
+
* chose.
|
|
346
384
|
*/
|
|
347
|
-
|
|
385
|
+
assertResultCaps(configured) {
|
|
348
386
|
if (configured !== undefined && !isValidMaxResultBytes(configured)) {
|
|
349
|
-
|
|
350
|
-
`bytes >= ${MIN_MAX_RESULT_BYTES}: it would serve an empty, ` +
|
|
351
|
-
"oversized, or unguarded result instead of truncating. Using the " +
|
|
352
|
-
`default ${DEFAULT_MAX_RESULT_BYTES} instead.`);
|
|
387
|
+
throw new Error(`ConnectaConfig.calls.maxResultBytes must be a whole number of bytes >= ${MIN_MAX_RESULT_BYTES}.`);
|
|
353
388
|
}
|
|
354
389
|
for (const c of this.connectors.values()) {
|
|
355
|
-
if (c.maxResultBytes !== undefined &&
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// literally what a call falls back to: meta-tools reads the inherited
|
|
359
|
-
// cap off `RegistryView.maxResultBytes`, so the warned value and the
|
|
360
|
-
// runtime value are the same field rather than two copies of it.
|
|
361
|
-
logger.warn(`[connecta] connector "${c.id}" sets maxResultBytes ` +
|
|
362
|
-
`${c.maxResultBytes}, which is not a whole number of bytes >= ` +
|
|
363
|
-
`${MIN_MAX_RESULT_BYTES}. Ignoring the override — the connector ` +
|
|
364
|
-
`inherits the deployment-wide cap calls fall back to ` +
|
|
365
|
-
`(${this.maxResultBytes}).`);
|
|
390
|
+
if (c.maxResultBytes !== undefined && !isValidMaxResultBytes(c.maxResultBytes)) {
|
|
391
|
+
throw new Error(`Connector "${c.id}" maxResultBytes must be a whole number of bytes >= ` +
|
|
392
|
+
`${MIN_MAX_RESULT_BYTES}; omit it to inherit the deployment-wide cap.`);
|
|
366
393
|
}
|
|
367
394
|
}
|
|
368
395
|
}
|
|
@@ -393,7 +420,13 @@ export class Registry {
|
|
|
393
420
|
getConnector(id) {
|
|
394
421
|
return this.connectors.get(id);
|
|
395
422
|
}
|
|
396
|
-
|
|
423
|
+
getResourceConnector(id) {
|
|
424
|
+
return this.getConnector(id);
|
|
425
|
+
}
|
|
426
|
+
canReadConnectorSkills(id) {
|
|
427
|
+
return this.connectors.has(id);
|
|
428
|
+
}
|
|
429
|
+
contextFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
|
|
397
430
|
const credentialConfig = this.connectors.get(id)?.credential;
|
|
398
431
|
let credentialAccess;
|
|
399
432
|
if (this.opts.credentialVault && credentialConfig) {
|
|
@@ -415,13 +448,42 @@ export class Registry {
|
|
|
415
448
|
};
|
|
416
449
|
}
|
|
417
450
|
const context = {
|
|
418
|
-
storage: namespaced(this.opts.storage,
|
|
451
|
+
storage: namespaced(this.opts.storage, scopes.connector(id)),
|
|
419
452
|
logger: this.opts.logger,
|
|
420
453
|
baseUrl,
|
|
454
|
+
...(this.opts.publicUrl !== undefined ? { publicUrl: this.opts.publicUrl } : {}),
|
|
455
|
+
...(this.opts.oauthClientName !== undefined ? { oauthClientName: this.opts.oauthClientName } : {}),
|
|
421
456
|
...(credentialAccess ? { credential: credentialAccess } : {}),
|
|
422
457
|
requestScope,
|
|
423
458
|
...callOptions,
|
|
424
459
|
};
|
|
460
|
+
attachCaller(context, scope?.caller);
|
|
461
|
+
attachCatalogCache(context, {
|
|
462
|
+
storage: this.opts.catalogStorage ?? this.opts.storage,
|
|
463
|
+
partition: JSON.stringify([
|
|
464
|
+
scope?.principalKey ?? this.opts.credentialOwner ?? null,
|
|
465
|
+
scope?.subjectKey ?? null,
|
|
466
|
+
scope?.caller?.identity ?? null,
|
|
467
|
+
scope?.caller?.authenticated ?? false,
|
|
468
|
+
scope?.caller?.pool ?? null,
|
|
469
|
+
]),
|
|
470
|
+
sharedPartition: JSON.stringify([baseUrl, this.opts.publicUrl ?? null, scope?.caller?.pool ?? null]),
|
|
471
|
+
defaultTtlMs: (this.opts.toolCacheTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds) * 1000,
|
|
472
|
+
minTtlMs: (this.opts.catalogMinTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMinTtlSeconds) * 1000,
|
|
473
|
+
maxTtlMs: (this.opts.catalogMaxTtlSeconds ?? CONFIG_DEFAULTS.discovery.catalogMaxTtlSeconds) * 1000,
|
|
474
|
+
...(this.opts.catalogDriftActivity?.recordChange
|
|
475
|
+
? {
|
|
476
|
+
onCompletedCatalogRefresh: (refresh, ctx) => this.recordCatalogDrift(refresh.previous, refresh.next, {
|
|
477
|
+
id,
|
|
478
|
+
connector: this.connectors.get(id),
|
|
479
|
+
ctx,
|
|
480
|
+
privateCatalog: refresh.private,
|
|
481
|
+
}),
|
|
482
|
+
}
|
|
483
|
+
: {}),
|
|
484
|
+
});
|
|
485
|
+
sentSecretsFor(context);
|
|
486
|
+
trackCredentialReads(context);
|
|
425
487
|
attachOAuthPartition(context, this.oauthPartition);
|
|
426
488
|
// Downstream OAuth state is sealed under the vault key, bound to this
|
|
427
489
|
// connector and owner. The sealer rides beside the context, not on it.
|
|
@@ -446,71 +508,28 @@ export class Registry {
|
|
|
446
508
|
snapshots.get(id)?.push(admission.snapshot());
|
|
447
509
|
}
|
|
448
510
|
}
|
|
449
|
-
return Object.fromEntries([...snapshots].map(([id, values]) => [
|
|
450
|
-
id, aggregateCallAdmissionSnapshots(values),
|
|
451
|
-
]));
|
|
452
|
-
}
|
|
453
|
-
/**
|
|
454
|
-
* Payload-free drift counts for the open health endpoint, so `connecta
|
|
455
|
-
* doctor` can report a stale allowlist without asking any downstream
|
|
456
|
-
* anything. Only connectors that ship a vetted manifest *and* have already
|
|
457
|
-
* served a refresh *in this runtime* appear — a process or isolate that has
|
|
458
|
-
* answered no catalog request yet honestly reports nothing, and drift is not
|
|
459
|
-
* persisted the way the catalog itself is.
|
|
460
|
-
*
|
|
461
|
-
* Every report is rebuilt by {@link boundedCatalogDrift} on the way out:
|
|
462
|
-
* `Connector.catalogDrift()` is the open plugin seam, and this snapshot is
|
|
463
|
-
* serialized into an unauthenticated response.
|
|
464
|
-
*/
|
|
465
|
-
catalogDriftSnapshot() {
|
|
466
|
-
const snapshot = {};
|
|
467
|
-
for (const connector of this.connectors.values()) {
|
|
468
|
-
const report = boundedCatalogDrift(connector.catalogDrift?.());
|
|
469
|
-
if (report)
|
|
470
|
-
snapshot[connector.id] = report;
|
|
471
|
-
}
|
|
472
|
-
return snapshot;
|
|
511
|
+
return Object.fromEntries([...snapshots].map(([id, values]) => [id, aggregateCallAdmissionSnapshots(values)]));
|
|
473
512
|
}
|
|
474
513
|
/**
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
* TTL is a heartbeat, not news, and the current counts are already on
|
|
479
|
-
* connector status. A first observation that is clean is not an event
|
|
480
|
-
* either — nothing moved — but a later return to clean is, because "the
|
|
481
|
-
* drift is gone" is exactly what an operator watching the timeline is
|
|
482
|
-
* waiting for.
|
|
514
|
+
* The drift a connector last showed in this runtime: the registry's own
|
|
515
|
+
* observation against a connector's `classification`, otherwise whatever
|
|
516
|
+
* the connector's `catalogDrift()` seam reports, bounded either way.
|
|
483
517
|
*/
|
|
484
|
-
|
|
485
|
-
const report = boundedCatalogDrift(connector.catalogDrift?.());
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
518
|
+
catalogDriftOf(connector) {
|
|
519
|
+
const report = boundedCatalogDrift(catalogReviewOf(connector) ? observedCatalogDrift(connector) : connector.catalogDrift?.());
|
|
520
|
+
const dropped = this.droppedToolNames.get(connector.id);
|
|
521
|
+
if (!dropped)
|
|
522
|
+
return report;
|
|
523
|
+
return {
|
|
524
|
+
...(report ?? {
|
|
525
|
+
unclassifiedTools: 0,
|
|
526
|
+
unservedTools: 0,
|
|
527
|
+
annotationConflicts: 0,
|
|
528
|
+
schemaChanges: 0,
|
|
529
|
+
}),
|
|
530
|
+
observedAt: dropped.observedAt,
|
|
531
|
+
...(dropped.count > 0 ? { droppedTools: dropped.count } : {}),
|
|
496
532
|
};
|
|
497
|
-
const unchanged = previous !== undefined &&
|
|
498
|
-
previous.unclassifiedTools === counts.unclassifiedTools &&
|
|
499
|
-
previous.unservedTools === counts.unservedTools &&
|
|
500
|
-
previous.annotationConflicts === counts.annotationConflicts &&
|
|
501
|
-
previous.schemaChanges === counts.schemaChanges;
|
|
502
|
-
if (unchanged)
|
|
503
|
-
return;
|
|
504
|
-
const clean = counts.unclassifiedTools === 0 &&
|
|
505
|
-
counts.unservedTools === 0 &&
|
|
506
|
-
counts.annotationConflicts === 0 &&
|
|
507
|
-
counts.schemaChanges === 0;
|
|
508
|
-
this.reportedDrift.set(connector.id, counts);
|
|
509
|
-
if (previous === undefined && clean)
|
|
510
|
-
return;
|
|
511
|
-
this.opts.catalogDriftActivity?.recordDrift?.(this.opts.catalogDriftActivity
|
|
512
|
-
? { ...this.opts.catalogDriftActivity, logger: this.opts.logger }
|
|
513
|
-
: undefined, { connectorId: connector.id, ...counts });
|
|
514
533
|
}
|
|
515
534
|
/** Reject queued/future downstream admission; active permits release safely. */
|
|
516
535
|
closeCallAdmission() {
|
|
@@ -521,75 +540,96 @@ export class Registry {
|
|
|
521
540
|
registry.closeCallAdmission();
|
|
522
541
|
}
|
|
523
542
|
/**
|
|
524
|
-
* Reserve capacity and write one ASCII paging envelope
|
|
543
|
+
* Reserve capacity and write one ASCII paging envelope.
|
|
525
544
|
*
|
|
526
|
-
* `chunks[
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
545
|
+
* `chunks[n]` lands on `resultKeys.chunk(id, n)` inside `partition` — the
|
|
546
|
+
* layout connecta.result reads back, so a page fetches only the chunks it covers
|
|
547
|
+
* instead of the whole stored result (issue #540). Chunking is a read-cost
|
|
548
|
+
* decision, not a capacity one: however many keys an envelope occupies, it
|
|
549
|
+
* is one stash entry charged its total ASCII length.
|
|
550
|
+
*
|
|
551
|
+
* The bounds are the deployment's, not this isolate's. Every charge is a
|
|
552
|
+
* row in one ledger record in storage, booked by compare-and-set before any
|
|
553
|
+
* chunk is written, so every isolate and process sharing the store sees the
|
|
554
|
+
* same entries and bytes. A charge leaves the ledger just after its
|
|
555
|
+
* result's deadline, which every chunk's TTL ends by; the storage TTL
|
|
556
|
+
* reclaims the rows themselves.
|
|
531
557
|
*/
|
|
532
|
-
stashResult(
|
|
533
|
-
// The fiber runs synchronously up to its first storage call. With nothing
|
|
534
|
-
// expired to reclaim, that is the first chunk write, after the
|
|
535
|
-
// reservation: a concurrent stash already sees this one's charge.
|
|
558
|
+
stashResult(id, chunks, ttlSeconds, partition = scopes.results) {
|
|
536
559
|
return runOnPartition(Effect.gen({ self: this }, function* () {
|
|
537
|
-
const maxBytes = this.opts.results?.maxStashBytes ??
|
|
538
|
-
const maxEntries = this.opts.results?.maxStashEntries ??
|
|
560
|
+
const maxBytes = this.opts.results?.maxStashBytes ?? CONFIG_DEFAULTS.results.maxStashBytes;
|
|
561
|
+
const maxEntries = this.opts.results?.maxStashEntries ?? CONFIG_DEFAULTS.results.maxStashEntries;
|
|
539
562
|
// The paging envelope is ASCII, so its string length is its stored byte count.
|
|
540
563
|
const bytes = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
541
564
|
if (bytes > maxBytes || maxEntries === 0)
|
|
542
565
|
return false;
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
if (this.resultStash.size >= maxEntries || this.resultStashBytes + bytes > maxBytes)
|
|
566
|
+
const keys = chunks.map((_, index) => partition + resultKeys.chunk(id, index));
|
|
567
|
+
const charge = keys[0];
|
|
568
|
+
// One deadline for the charge and every chunk under it. Writes take
|
|
569
|
+
// time, so a chunk's TTL is whatever remains of the deadline when it is
|
|
570
|
+
// written, never a fresh `ttlSeconds`, and no single write may take
|
|
571
|
+
// longer than the grace: no chunk outlives the charge that bounds it.
|
|
572
|
+
const deadline = yield* swapStashLedger((live, now) => {
|
|
573
|
+
const used = live.reduce((sum, entry) => sum + entry[1], 0);
|
|
574
|
+
if (live.length >= maxEntries || used + bytes > maxBytes)
|
|
575
|
+
return { result: undefined };
|
|
576
|
+
const end = now + ttlSeconds * 1000;
|
|
577
|
+
return { entries: [...live, [charge, bytes, end + STASH_LEDGER_GRACE_MS]], result: end };
|
|
578
|
+
});
|
|
579
|
+
if (deadline === undefined)
|
|
559
580
|
return false;
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
581
|
+
// A failed write may still have persisted. Delete every key it could
|
|
582
|
+
// have written and release the charge only when all of them are gone;
|
|
583
|
+
// otherwise the charge stays booked until it expires, by which time the
|
|
584
|
+
// storage TTL has removed whatever did land.
|
|
585
|
+
const release = Effect.gen(function* () {
|
|
586
|
+
for (const key of keys)
|
|
587
|
+
yield* storageDelete(key);
|
|
588
|
+
yield* swapStashLedger((live) => live.some((entry) => entry[0] === charge)
|
|
589
|
+
? { entries: live.filter((entry) => entry[0] !== charge), result: undefined }
|
|
590
|
+
: { result: undefined });
|
|
591
|
+
}).pipe(Effect.ignore);
|
|
565
592
|
// Trailing chunks first: the header chunk is what makes an id readable, so
|
|
566
593
|
// a write that fails midway leaves no envelope pointing at absent chunks.
|
|
567
|
-
yield* Effect.gen(function* () {
|
|
594
|
+
const written = yield* Effect.gen(function* () {
|
|
568
595
|
for (let index = keys.length - 1; index >= 0; index--) {
|
|
569
|
-
|
|
596
|
+
const before = yield* Clock.currentTimeMillis;
|
|
597
|
+
const remaining = Math.floor((deadline - before) / 1000);
|
|
598
|
+
// Zero would mean no expiry: a stash that outlasts its deadline fails.
|
|
599
|
+
if (remaining < 1)
|
|
600
|
+
return false;
|
|
601
|
+
yield* storageSet(keys[index], chunks[index], { ttlSeconds: remaining });
|
|
602
|
+
if ((yield* Clock.currentTimeMillis) - before > STASH_LEDGER_GRACE_MS)
|
|
603
|
+
return false;
|
|
570
604
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
? writtenAt + ttlSeconds * 1000
|
|
577
|
-
: 0;
|
|
578
|
-
entry.busy = false;
|
|
579
|
-
}))));
|
|
580
|
-
return true;
|
|
605
|
+
return true;
|
|
606
|
+
}).pipe(Effect.onError(() => release));
|
|
607
|
+
if (!written)
|
|
608
|
+
yield* release;
|
|
609
|
+
return written;
|
|
581
610
|
}), this.opts);
|
|
582
611
|
}
|
|
583
612
|
/**
|
|
584
|
-
* Storage namespaced to the
|
|
585
|
-
*
|
|
613
|
+
* Storage namespaced to the root result partition, kept separate from any
|
|
614
|
+
* connector's namespace. Backs connecta.result.
|
|
586
615
|
*/
|
|
616
|
+
resultIdentity() {
|
|
617
|
+
return { subject: null, principal: null, endpoint: "/mcp", origin: null };
|
|
618
|
+
}
|
|
619
|
+
recheckResultAccess(_address, _classification, _signal) {
|
|
620
|
+
return Promise.resolve(true);
|
|
621
|
+
}
|
|
587
622
|
resultsStorage() {
|
|
588
|
-
return namespaced(this.opts.storage,
|
|
623
|
+
return namespaced(this.opts.storage, scopes.results);
|
|
624
|
+
}
|
|
625
|
+
credentialUiAvailable() {
|
|
626
|
+
return Boolean(this.opts.credentialUi);
|
|
589
627
|
}
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
628
|
+
async bindOAuthHandoff(id, authorizationUrl, principalKey) {
|
|
629
|
+
const state = new URL(authorizationUrl).searchParams.get("state");
|
|
630
|
+
if (!state || !principalKey)
|
|
631
|
+
throw new Error("OAuth requires state and an initiating user");
|
|
632
|
+
await this.storeOAuthHandoff(id, state, principalKey);
|
|
593
633
|
}
|
|
594
634
|
observedOutputSchema(connectorId, definition) {
|
|
595
635
|
return this.observedOutputSchemas.get(connectorId, definition);
|
|
@@ -607,608 +647,143 @@ export class Registry {
|
|
|
607
647
|
return null;
|
|
608
648
|
return { connector, toolName: parts.toolName };
|
|
609
649
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
!Number.isInteger(manifest.chunkCount) ||
|
|
637
|
-
manifest.chunkCount < 1 ||
|
|
638
|
-
manifest.chunkCount > maxChunks ||
|
|
639
|
-
typeof manifest.fetchedAt !== "number" ||
|
|
640
|
-
typeof manifest.expiresAt !== "number" ||
|
|
641
|
-
typeof manifest.staleUntil !== "number") {
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
return manifest;
|
|
645
|
-
}
|
|
646
|
-
parseCatalogManifest(raw) {
|
|
647
|
-
if (!raw)
|
|
648
|
-
return null;
|
|
650
|
+
/** Compare only the fingerprints accepted in this exact cache partition. */
|
|
651
|
+
recordCatalogDrift(previous, next, attribution) {
|
|
652
|
+
if (!previous)
|
|
653
|
+
return;
|
|
654
|
+
const { id, connector, ctx, privateCatalog } = attribution;
|
|
655
|
+
const before = new Map(previous.map((tool) => [tool.name, tool.fact]));
|
|
656
|
+
const after = new Map(next.map((tool) => [tool.name, tool.fact]));
|
|
657
|
+
const addedTools = [...after.keys()].filter((name) => !before.has(name)).length;
|
|
658
|
+
const removedTools = [...before.keys()].filter((name) => !after.has(name)).length;
|
|
659
|
+
const changedTools = [...after].filter(([name, fact]) => before.has(name) && before.get(name) !== fact).length;
|
|
660
|
+
if (addedTools || removedTools || changedTools)
|
|
661
|
+
this.opts.catalogDriftActivity?.recordChange?.({ ...this.opts.catalogDriftActivity, logger: this.opts.logger, ...(ctx.defer ? { defer: ctx.defer } : {}) }, {
|
|
662
|
+
connectorId: id,
|
|
663
|
+
drift: { kind: "catalog_changed", addedTools, removedTools, changedTools },
|
|
664
|
+
privateCatalog,
|
|
665
|
+
...(connector.authScope === "personal" ? { personal: true } : {}),
|
|
666
|
+
}, activityRequest(ctx.requestScope));
|
|
667
|
+
}
|
|
668
|
+
async loadDownstreamTools(id, baseUrl, requestScope, callOptions = {}, scope) {
|
|
669
|
+
const connector = this.connectors.get(id);
|
|
670
|
+
if (!connector)
|
|
671
|
+
throw new Error(`Unknown connector "${id}"`);
|
|
672
|
+
if (connector.staticTools)
|
|
673
|
+
return markCatalogFreshness(connector.staticTools, Infinity);
|
|
674
|
+
const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
|
|
675
|
+
let listed;
|
|
649
676
|
try {
|
|
650
|
-
|
|
677
|
+
listed = await connector.listTools(ctx);
|
|
651
678
|
}
|
|
652
|
-
catch {
|
|
653
|
-
|
|
679
|
+
catch (error) {
|
|
680
|
+
const fallback = error instanceof ConnectorCallError && error.code === "unavailable"
|
|
681
|
+
? await customCatalogFallback(ctx, id).catch(() => undefined)
|
|
682
|
+
: undefined;
|
|
683
|
+
if (!fallback)
|
|
684
|
+
throw sentSecretsForRequest(ctx.requestScope ?? ctx).redact(error);
|
|
685
|
+
listed = fallback;
|
|
654
686
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
fatal: true,
|
|
660
|
-
ignoreBOM: true,
|
|
661
|
-
});
|
|
662
|
-
let offset = 0;
|
|
663
|
-
while (offset < snapshot.serializedBytes.byteLength) {
|
|
664
|
-
let end = Math.min(offset + MAX_CATALOG_CHUNK_BYTES, snapshot.serializedBytes.byteLength);
|
|
665
|
-
// Move a boundary that landed inside a multibyte UTF-8 sequence back to
|
|
666
|
-
// the next character start. Every stored string then remains valid UTF-8.
|
|
667
|
-
while (end < snapshot.serializedBytes.byteLength &&
|
|
668
|
-
((snapshot.serializedBytes[end] ?? 0) & 0xc0) === 0x80) {
|
|
669
|
-
end--;
|
|
670
|
-
}
|
|
671
|
-
chunks.push(decoder.decode(snapshot.serializedBytes.subarray(offset, end)));
|
|
672
|
-
offset = end;
|
|
687
|
+
const tools = redactCatalog(ctx, listed).map(({ classification: _ignored, ...fact }) => fact);
|
|
688
|
+
if (tools.length > MAX_CATALOG_TOOLS ||
|
|
689
|
+
encoder.encode(JSON.stringify(tools)).byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
690
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", { retryable: false });
|
|
673
691
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
readCatalog(id, now) {
|
|
682
|
-
return Effect.gen({ self: this }, function* () {
|
|
683
|
-
const raw = yield* storageGet(this.catalogKey(id));
|
|
684
|
-
if (!raw)
|
|
685
|
-
return null;
|
|
686
|
-
let parsed;
|
|
692
|
+
const facts = frozenFacts(structuredClone(tools));
|
|
693
|
+
const accepted = this.acceptToolNames(facts);
|
|
694
|
+
this.observeDroppedToolNames(connector, facts.length - accepted.length);
|
|
695
|
+
const review = catalogReviewOf(connector);
|
|
696
|
+
if (review)
|
|
697
|
+
await observeReviewedDrift(connector, review, accepted, this.opts.logger);
|
|
698
|
+
if (catalogFetchedAt(ctx) === undefined) {
|
|
687
699
|
try {
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
catch {
|
|
691
|
-
return null;
|
|
700
|
+
await storeCustomCatalogFallback(ctx, id, facts);
|
|
692
701
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
if (!manifest) {
|
|
696
|
-
logger.warn(`[connecta] connector "${id}" catalog manifest is invalid; ignoring persisted catalog.`);
|
|
697
|
-
return null;
|
|
702
|
+
catch (error) {
|
|
703
|
+
logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
|
|
698
704
|
}
|
|
699
|
-
if (manifest.staleUntil <= now)
|
|
700
|
-
return null;
|
|
701
|
-
const chunkReads = yield* forEachChunk(Array.from({ length: manifest.chunkCount }, (_, index) => index), (index) => storageGet(this.catalogChunkKey(id, manifest.revision, index)));
|
|
702
|
-
const chunks = [];
|
|
703
|
-
let chunkBytes = 0;
|
|
704
|
-
for (const [index, read] of chunkReads.entries()) {
|
|
705
|
-
if (Result.isFailure(read))
|
|
706
|
-
return yield* Effect.fail(read.failure);
|
|
707
|
-
const chunk = read.success;
|
|
708
|
-
if (chunk === null) {
|
|
709
|
-
logger.warn(`[connecta] connector "${id}" catalog chunk ${index + 1}/${manifest.chunkCount} is missing; ignoring persisted catalog.`);
|
|
710
|
-
return null;
|
|
711
|
-
}
|
|
712
|
-
const byteLength = encoder.encode(chunk).byteLength;
|
|
713
|
-
chunkBytes += byteLength;
|
|
714
|
-
if (byteLength > MAX_CATALOG_CHUNK_BYTES ||
|
|
715
|
-
chunkBytes > manifest.byteCount) {
|
|
716
|
-
logger.warn(`[connecta] connector "${id}" catalog chunk bounds do not match its manifest; ignoring persisted catalog.`);
|
|
717
|
-
return null;
|
|
718
|
-
}
|
|
719
|
-
chunks.push(chunk);
|
|
720
|
-
}
|
|
721
|
-
const serializedTools = chunks.join("");
|
|
722
|
-
const stored = yield* Effect.tryPromise({
|
|
723
|
-
try: () => fingerprintSerializedCatalog(serializedTools),
|
|
724
|
-
catch: (error) => error,
|
|
725
|
-
});
|
|
726
|
-
if (stored.byteLength !== manifest.byteCount ||
|
|
727
|
-
stored.fingerprint !== manifest.revision) {
|
|
728
|
-
logger.warn(`[connecta] connector "${id}" catalog fingerprint mismatch; ignoring persisted catalog.`);
|
|
729
|
-
return null;
|
|
730
|
-
}
|
|
731
|
-
let tools;
|
|
732
705
|
try {
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
catch {
|
|
736
|
-
logger.warn(`[connecta] connector "${id}" catalog chunks are torn; ignoring persisted catalog.`);
|
|
737
|
-
return null;
|
|
738
|
-
}
|
|
739
|
-
if (!Array.isArray(tools) || !this.validCatalogTools(tools)) {
|
|
740
|
-
logger.warn(`[connecta] connector "${id}" catalog chunks contain invalid tools; ignoring persisted catalog.`);
|
|
741
|
-
return null;
|
|
706
|
+
await observeUncachedCatalogRefresh(ctx, id, facts);
|
|
742
707
|
}
|
|
743
|
-
|
|
744
|
-
logger
|
|
745
|
-
return null;
|
|
708
|
+
catch (error) {
|
|
709
|
+
logFailure(this.opts.logger, "catalog refresh observation failed", failureRecord({ connector: id }, error));
|
|
746
710
|
}
|
|
747
|
-
return {
|
|
748
|
-
tools,
|
|
749
|
-
fingerprint: stored.fingerprint,
|
|
750
|
-
fetchedAt: manifest.fetchedAt,
|
|
751
|
-
expiresAt: manifest.expiresAt,
|
|
752
|
-
staleUntil: manifest.staleUntil,
|
|
753
|
-
};
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
// Persist one complete catalog: every chunk, then the manifest. A snapshot
|
|
757
|
-
// only reaches here after the tool and byte ceilings accepted it whole.
|
|
758
|
-
storeCatalog(id, snapshot) {
|
|
759
|
-
return Effect.gen({ self: this }, function* () {
|
|
760
|
-
if (!this.persistToolCatalog)
|
|
761
|
-
return;
|
|
762
|
-
const fetchedAt = yield* Clock.currentTimeMillis;
|
|
763
|
-
const expiresAt = fetchedAt + this.ttlMs;
|
|
764
|
-
const staleUntil = expiresAt + this.staleMs;
|
|
765
|
-
const ttlSeconds = Math.max(60, Math.ceil((this.ttlMs + this.staleMs) / 1000));
|
|
766
|
-
const chunks = this.splitCatalogChunks(snapshot);
|
|
767
|
-
const chunkWrites = yield* forEachChunk(chunks, (chunk, index) => storageSet(this.catalogChunkKey(id, snapshot.fingerprint, index), chunk, {
|
|
768
|
-
ttlSeconds: ttlSeconds + CATALOG_CHUNK_TTL_GRACE_SECONDS,
|
|
769
|
-
}));
|
|
770
|
-
for (const write of chunkWrites) {
|
|
771
|
-
if (Result.isFailure(write))
|
|
772
|
-
return yield* Effect.fail(write.failure);
|
|
773
|
-
}
|
|
774
|
-
// The manifest is the only publication point. A failed/partial chunk write
|
|
775
|
-
// therefore leaves the previous manifest authoritative (or no catalog);
|
|
776
|
-
// unreachable chunks carry a bounded TTL and require no prefix scan.
|
|
777
|
-
const manifest = {
|
|
778
|
-
version: 2,
|
|
779
|
-
revision: snapshot.fingerprint,
|
|
780
|
-
toolCount: snapshot.tools.length,
|
|
781
|
-
byteCount: snapshot.serializedBytes.byteLength,
|
|
782
|
-
chunkCount: chunks.length,
|
|
783
|
-
fetchedAt,
|
|
784
|
-
expiresAt,
|
|
785
|
-
staleUntil,
|
|
786
|
-
};
|
|
787
|
-
yield* storageSet(this.catalogKey(id), JSON.stringify(manifest), {
|
|
788
|
-
ttlSeconds,
|
|
789
|
-
});
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
// Remove the persisted catalog: the manifest and every chunk it names, all
|
|
793
|
-
// at once under the chunk I/O bound. Every delete is attempted; the effect
|
|
794
|
-
// fails with the manifest read's error first, then the manifest delete's,
|
|
795
|
-
// then the first chunk delete's by index.
|
|
796
|
-
deleteCatalog(id) {
|
|
797
|
-
return Effect.gen({ self: this }, function* () {
|
|
798
|
-
const read = yield* Effect.result(storageGet(this.catalogKey(id)));
|
|
799
|
-
const manifest = Result.isSuccess(read)
|
|
800
|
-
? this.parseCatalogManifest(read.success)
|
|
801
|
-
: null;
|
|
802
|
-
// The root is authoritative, so attempt its deletion even when the
|
|
803
|
-
// best-effort read needed for physical chunk cleanup failed. It need
|
|
804
|
-
// not land first: a reader that finds the root but not a chunk has an
|
|
805
|
-
// incomplete catalog, which is never served, and an eventually
|
|
806
|
-
// consistent store could show it that way whatever the order.
|
|
807
|
-
const keys = [
|
|
808
|
-
this.catalogKey(id),
|
|
809
|
-
...Array.from({ length: manifest?.chunkCount ?? 0 }, (_, index) => this.catalogChunkKey(id, manifest.revision, index)),
|
|
810
|
-
];
|
|
811
|
-
const deleted = yield* forEachChunk(keys, (key) => storageDelete(key));
|
|
812
|
-
if (Result.isFailure(read))
|
|
813
|
-
return yield* Effect.fail(read.failure);
|
|
814
|
-
const failed = deleted.find(Result.isFailure);
|
|
815
|
-
if (failed)
|
|
816
|
-
return yield* Effect.fail(failed.failure);
|
|
817
|
-
});
|
|
818
|
-
}
|
|
819
|
-
catalogGeneration(id) {
|
|
820
|
-
return this.catalogGenerations.get(id) ?? 0;
|
|
821
|
-
}
|
|
822
|
-
advanceCatalogGeneration(id) {
|
|
823
|
-
this.catalogGenerations.set(id, this.catalogGeneration(id) + 1);
|
|
824
|
-
}
|
|
825
|
-
/**
|
|
826
|
-
* Keep this isolate's writes and invalidations ordered. Without the queue, an
|
|
827
|
-
* old refresh can finish its storage.set after a credential change deletes
|
|
828
|
-
* the catalog and resurrect the pre-change listing.
|
|
829
|
-
*/
|
|
830
|
-
async enqueueCatalogMutation(id, operation) {
|
|
831
|
-
// Mutations run in arrival order, each taking its turn from the one
|
|
832
|
-
// queued before it. That one may belong to another request, and
|
|
833
|
-
// completing a Deferred resumes its waiters inside the completing call,
|
|
834
|
-
// so the wait is an edge of its own and this request's storage work
|
|
835
|
-
// starts only after it. (Effect's Semaphore would do neither: it resumes
|
|
836
|
-
// a waiter from the releasing fiber, and a newcomer can take the permit
|
|
837
|
-
// before a waiter that queued earlier.) An operation logs its own storage
|
|
838
|
-
// failure, so a turn always ends.
|
|
839
|
-
const previous = this.catalogMutations.get(id);
|
|
840
|
-
const turn = Deferred.makeUnsafe();
|
|
841
|
-
this.catalogMutations.set(id, turn);
|
|
842
|
-
try {
|
|
843
|
-
if (previous) {
|
|
844
|
-
// A cross-request Deferred alone looks hung to workerd. Keep a timer
|
|
845
|
-
// owned by this request until the preceding mutation hands over its
|
|
846
|
-
// turn; the race interrupts the timer as soon as the wait settles.
|
|
847
|
-
await runEdge(Effect.raceFirst(Deferred.await(previous), Effect.forever(Effect.sleep(Duration.seconds(1)))));
|
|
848
|
-
}
|
|
849
|
-
await runOnPartition(operation, this.opts);
|
|
850
|
-
}
|
|
851
|
-
finally {
|
|
852
|
-
if (this.catalogMutations.get(id) === turn) {
|
|
853
|
-
this.catalogMutations.delete(id);
|
|
854
|
-
}
|
|
855
|
-
Deferred.doneUnsafe(turn, Exit.void);
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
async refreshToolsWithContext(id, connector, ctx, skipPublicationWhenAborted = false, flight) {
|
|
859
|
-
const generation = this.catalogGeneration(id);
|
|
860
|
-
const tools = await connector.listTools(ctx);
|
|
861
|
-
// The listing a maintained proxy just served is also the only catalog
|
|
862
|
-
// comparison connecta ever makes. It rides this refresh whether or not the
|
|
863
|
-
// result reaches a cache, because what drifted drifted.
|
|
864
|
-
this.observeCatalogDrift(connector);
|
|
865
|
-
// A deferred deadline may close the owned scope while a connector that
|
|
866
|
-
// ignores abort is still listing. The completed list remains a valid drift
|
|
867
|
-
// observation, but must not overwrite a newer same-generation refresh.
|
|
868
|
-
// Blocking callers do not set this flag and retain their prior publication
|
|
869
|
-
// behavior when an inbound abort races a completed listing.
|
|
870
|
-
// Every returned listing must pass the ceilings, even when invalidation
|
|
871
|
-
// or abandonment means it can no longer enter either shared cache layer.
|
|
872
|
-
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
873
|
-
const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
874
|
-
`${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
|
|
875
|
-
this.opts.logger.warn(`[connecta] ${message}`);
|
|
876
|
-
throw new Error(message);
|
|
877
|
-
}
|
|
878
|
-
const previous = this.cache.get(id);
|
|
879
|
-
const snapshot = await snapshotCatalog(tools);
|
|
880
|
-
if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
881
|
-
const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
|
|
882
|
-
`serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
|
|
883
|
-
"refusing the complete catalog.";
|
|
884
|
-
this.opts.logger.warn(`[connecta] ${message}`);
|
|
885
|
-
throw new Error(message);
|
|
886
|
-
}
|
|
887
|
-
// The caller that began this refresh may still use its bounded result,
|
|
888
|
-
// but credential changes, abandoned flights, and deferred cancellation
|
|
889
|
-
// prevent publication. Recheck after snapshotting, which is asynchronous.
|
|
890
|
-
if (!this.mayPublish(id, generation, flight) ||
|
|
891
|
-
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
892
|
-
return tools;
|
|
893
711
|
}
|
|
894
|
-
|
|
895
|
-
const
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
tools,
|
|
901
|
-
fingerprint: snapshot.fingerprint,
|
|
902
|
-
exp: now + this.ttlMs,
|
|
903
|
-
staleUntil: now + this.ttlMs + this.staleMs,
|
|
712
|
+
this.catalogObservedAt.set(id, catalogFetchedAt(ctx) ?? Date.now());
|
|
713
|
+
const provenance = catalogExpiry(ctx);
|
|
714
|
+
const freshUntil = provenance && !provenance.staleFallback ? provenance.expiresAt : 0;
|
|
715
|
+
this.catalogAccess.set(id, {
|
|
716
|
+
state: freshUntil > Date.now() ? "fresh" : "stale",
|
|
717
|
+
observedAt: new Date().toISOString(),
|
|
904
718
|
});
|
|
905
|
-
|
|
906
|
-
if (shouldPersist) {
|
|
907
|
-
await this.enqueueCatalogMutation(id,
|
|
908
|
-
// The generation is read when the turn comes, not when it is queued:
|
|
909
|
-
// an invalidation queued meanwhile has already deleted the catalog.
|
|
910
|
-
warnOnFailure(Effect.suspend(() => generation === this.catalogGeneration(id)
|
|
911
|
-
? this.storeCatalog(id, snapshot)
|
|
912
|
-
: Effect.void), `connector "${id}" catalog persistence failed`));
|
|
913
|
-
}
|
|
914
|
-
return tools;
|
|
719
|
+
return markCatalogFreshness(facts, freshUntil);
|
|
915
720
|
}
|
|
916
|
-
/**
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
* snapshotting both yield, and a late result must never overwrite the one a
|
|
921
|
-
* fresh attempt published in the meantime.
|
|
922
|
-
*/
|
|
923
|
-
mayPublish(id, generation, flight) {
|
|
924
|
-
return (generation === this.catalogGeneration(id) &&
|
|
925
|
-
(!flight || (!flight.abandoned && Date.now() < flight.abandonAt)));
|
|
721
|
+
/** The served catalog excludes control-character names; raw caches stay complete. */
|
|
722
|
+
acceptToolNames(tools) {
|
|
723
|
+
const accepted = tools.filter((tool) => !hasControlCharacters(tool.name));
|
|
724
|
+
return accepted.length === tools.length ? tools : accepted;
|
|
926
725
|
}
|
|
927
|
-
/**
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
* that context. A flight found past its bound is abandoned here, and this
|
|
932
|
-
* caller starts the fresh attempt.
|
|
933
|
-
*/
|
|
934
|
-
startCatalogRefresh(id, generation, boundMs, work, ownerLeft = () => false) {
|
|
935
|
-
const existing = this.catalogRefreshes.get(id);
|
|
936
|
-
if (existing?.generation === generation) {
|
|
937
|
-
if (Date.now() < existing.abandonAt)
|
|
938
|
-
return { flight: existing };
|
|
939
|
-
this.abandonCatalogRefresh(id, existing);
|
|
726
|
+
/** Observe intake once, never while serving an older cached listing. */
|
|
727
|
+
observeDroppedToolNames(connector, count) {
|
|
728
|
+
if (count > 0 || this.droppedToolNames.has(connector.id)) {
|
|
729
|
+
this.droppedToolNames.set(connector.id, { count, observedAt: new Date().toISOString() });
|
|
940
730
|
}
|
|
941
|
-
const flight = {
|
|
942
|
-
generation,
|
|
943
|
-
abandonAt: Date.now() + boundMs,
|
|
944
|
-
abandoned: false,
|
|
945
|
-
outcome: Deferred.makeUnsafe(),
|
|
946
|
-
};
|
|
947
|
-
this.catalogRefreshes.set(id, flight);
|
|
948
|
-
const owner = Effect.onExit(work(flight), (exit) => Effect.sync(() => {
|
|
949
|
-
if (this.catalogRefreshes.get(id) === flight) {
|
|
950
|
-
this.catalogRefreshes.delete(id);
|
|
951
|
-
}
|
|
952
|
-
// A failure that was the owner's alone is no answer for anyone
|
|
953
|
-
// else: its own cancellation (its signal, read here in its own
|
|
954
|
-
// request), or anything that arrives once the flight is past its
|
|
955
|
-
// bound. Joiners make a fresh attempt under their own deadlines.
|
|
956
|
-
const ownersAlone = Exit.isFailure(exit) &&
|
|
957
|
-
(flight.abandoned ||
|
|
958
|
-
Date.now() >= flight.abandonAt ||
|
|
959
|
-
ownerLeft());
|
|
960
|
-
// Last, because joined fibers resume inside this call.
|
|
961
|
-
Deferred.doneUnsafe(flight.outcome, ownersAlone ? Exit.fail(new AbandonedCatalogRefresh(id)) : exit);
|
|
962
|
-
}));
|
|
963
|
-
return { flight, owner };
|
|
964
731
|
}
|
|
965
732
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
733
|
+
* One connector's catalog as served: the cached downstream facts, classified
|
|
734
|
+
* on every read by the connector's `classification`, into fresh objects.
|
|
735
|
+
* Safety is derived here and nowhere else, so no cache layer can carry a
|
|
736
|
+
* stale verdict — from an older review, an older release, or a stale
|
|
737
|
+
* fallback — and nothing a caller or decorator does to a served tool reaches
|
|
738
|
+
* the next read.
|
|
970
739
|
*/
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
return;
|
|
974
|
-
flight.abandoned = true;
|
|
975
|
-
if (this.catalogRefreshes.get(id) === flight) {
|
|
976
|
-
this.catalogRefreshes.delete(id);
|
|
977
|
-
}
|
|
978
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh outlived its bound; abandoning it for a fresh attempt.`);
|
|
979
|
-
Deferred.doneUnsafe(flight.outcome, Exit.fail(new AbandonedCatalogRefresh(id)));
|
|
980
|
-
}
|
|
981
|
-
/**
|
|
982
|
-
* Wait on another caller's flight, no longer than its bound. Succeeds with
|
|
983
|
-
* its tools, or with `undefined` when it will not answer for this caller —
|
|
984
|
-
* past its bound, when this waiter abandons it, or failed for its owner
|
|
985
|
-
* alone — so the caller makes a fresh attempt. The timer is this waiter's
|
|
986
|
-
* own: on workerd it is what keeps a request that waits on another alive,
|
|
987
|
-
* and bounded.
|
|
988
|
-
*/
|
|
989
|
-
joinCatalogRefresh(id, flight) {
|
|
990
|
-
return Effect.suspend(() => Deferred.await(flight.outcome).pipe(Effect.timeoutOption(Duration.millis(Math.max(0, flight.abandonAt - Date.now()))), Effect.flatMap((joined) => Option.isSome(joined)
|
|
991
|
-
? Effect.succeed(joined.value)
|
|
992
|
-
: Effect.sync(() => {
|
|
993
|
-
this.abandonCatalogRefresh(id, flight);
|
|
994
|
-
return undefined;
|
|
995
|
-
})), Effect.catch((error) => error instanceof AbandonedCatalogRefresh
|
|
996
|
-
? Effect.succeed(undefined)
|
|
997
|
-
: Effect.fail(error))));
|
|
998
|
-
}
|
|
999
|
-
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
1000
|
-
async refreshTools(id, baseUrl, requestScope, callOptions = {}) {
|
|
740
|
+
async loadTools(id, baseUrl, requestScope, callOptions = {}, scope) {
|
|
741
|
+
const tools = await this.loadDownstreamTools(id, baseUrl, requestScope, callOptions, scope);
|
|
1001
742
|
const connector = this.connectors.get(id);
|
|
1002
743
|
if (!connector)
|
|
1003
744
|
throw new Error(`Unknown connector "${id}"`);
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
observeCatalogAccess(id, state) {
|
|
1029
|
-
this.catalogAccess.set(id, {
|
|
1030
|
-
state,
|
|
1031
|
-
observedAt: new Date().toISOString(),
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
/**
|
|
1035
|
-
* Start or join one shared refresh. A newly deferred task owns its scope,
|
|
1036
|
-
* deadline, and teardown; no inbound signal or request scope crosses into it.
|
|
1037
|
-
*/
|
|
1038
|
-
deferCatalogRefresh(id, baseUrl, expectedGeneration, options) {
|
|
1039
|
-
const connector = this.connectors.get(id);
|
|
1040
|
-
const defer = options.defer;
|
|
1041
|
-
if (!connector || connector.staticTools || !defer)
|
|
1042
|
-
return;
|
|
1043
|
-
// The refresh this request starts, if none of this generation is live.
|
|
1044
|
-
// Its scope is closed however it ends, deadline included, before the
|
|
1045
|
-
// flight completes.
|
|
1046
|
-
const refresh = (flight) => withDeadlineEffect((signal) => Effect.gen({ self: this }, function* () {
|
|
1047
|
-
const current = this.cache.get(id);
|
|
1048
|
-
if (current && current.exp > Date.now())
|
|
1049
|
-
return current.tools;
|
|
1050
|
-
if (expectedGeneration !== this.catalogGeneration(id) ||
|
|
1051
|
-
this.invalidated.has(id)) {
|
|
1052
|
-
return yield* Effect.fail(new Error(`Deferred catalog refresh of "${id}" was invalidated before it started.`));
|
|
1053
|
-
}
|
|
1054
|
-
const ctx = this.contextFor(id, baseUrl, {}, {
|
|
1055
|
-
signal,
|
|
1056
|
-
timeoutMs: options.refreshTimeoutMs,
|
|
1057
|
-
});
|
|
1058
|
-
yield* closeScopeOnExit(connector, ctx, defer);
|
|
1059
|
-
return yield* Effect.tryPromise({
|
|
1060
|
-
try: () => this.refreshToolsWithContext(id, connector, ctx, true, flight),
|
|
1061
|
-
catch: (error) => error,
|
|
1062
|
-
});
|
|
1063
|
-
}), {
|
|
1064
|
-
timeoutMs: options.refreshTimeoutMs,
|
|
1065
|
-
timeoutError: new Error(`deferred catalog refresh of "${id}" timed out after ${options.refreshTimeoutMs}ms`),
|
|
1066
|
-
});
|
|
1067
|
-
const logFailure = (err) => {
|
|
1068
|
-
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh failed: ${msg(err)}`);
|
|
745
|
+
const accepted = this.acceptToolNames(tools);
|
|
746
|
+
const overrides = this.classification[id];
|
|
747
|
+
this.validateClassification(id, accepted, overrides);
|
|
748
|
+
const review = catalogReviewOf(connector);
|
|
749
|
+
return carryCatalogFreshness(tools, review
|
|
750
|
+
? await classifyCatalog(review, id, accepted, this.opts.logger, this.verifiedFacts, overrides)
|
|
751
|
+
: accepted.map((tool) => this.publishUnreviewedTool(id, tool)));
|
|
752
|
+
}
|
|
753
|
+
publishUnreviewedTool(id, tool) {
|
|
754
|
+
const override = this.classification[id]?.[tool.name];
|
|
755
|
+
return {
|
|
756
|
+
...structuredClone(tool),
|
|
757
|
+
classification: classifyTool(tool, override),
|
|
758
|
+
...(override !== undefined
|
|
759
|
+
? {
|
|
760
|
+
annotations: {
|
|
761
|
+
...structuredClone(tool.annotations),
|
|
762
|
+
readOnlyHint: override === "read",
|
|
763
|
+
destructiveHint: override === "write",
|
|
764
|
+
},
|
|
765
|
+
}
|
|
766
|
+
: {}),
|
|
1069
767
|
};
|
|
1070
|
-
const { flight, owner } = this.startCatalogRefresh(id, expectedGeneration, options.refreshTimeoutMs, (flight) => Effect.scoped(refresh(flight)));
|
|
1071
|
-
if (owner) {
|
|
1072
|
-
// Runs past this response, under the runtime's waitUntil below.
|
|
1073
|
-
flight.deferredTail = detach(Effect.catchCause(owner, (cause) => Effect.sync(() => logFailure(Cause.squash(cause)))));
|
|
1074
|
-
}
|
|
1075
|
-
// A stale reader that joins a blocking refresh logs its failure once for
|
|
1076
|
-
// every reader after it, and waits through an edge of its own, no longer
|
|
1077
|
-
// than the flight's bound.
|
|
1078
|
-
flight.deferredTail ??= runEdge(this.joinCatalogRefresh(id, flight)).then(() => { }, logFailure);
|
|
1079
|
-
try {
|
|
1080
|
-
defer(flight.deferredTail);
|
|
1081
|
-
}
|
|
1082
|
-
catch (err) {
|
|
1083
|
-
this.opts.logger.warn(`[connecta] connector "${id}" deferred catalog refresh could not attach to the runtime: ${msg(err)}`);
|
|
1084
|
-
}
|
|
1085
768
|
}
|
|
1086
|
-
/**
|
|
1087
|
-
|
|
1088
|
-
const
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
const
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
if (hit && hit.exp > now) {
|
|
1097
|
-
if (readOptions?.defer)
|
|
1098
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1099
|
-
return hit.tools;
|
|
1100
|
-
}
|
|
1101
|
-
let stale = hit && hit.staleUntil > now
|
|
1102
|
-
? { tools: hit.tools, staleUntil: hit.staleUntil }
|
|
1103
|
-
: undefined;
|
|
1104
|
-
if (this.persistToolCatalog && !this.invalidated.has(id)) {
|
|
1105
|
-
const generation = this.catalogGeneration(id);
|
|
1106
|
-
let persisted = null;
|
|
1107
|
-
try {
|
|
1108
|
-
persisted = await runOnPartition(this.readCatalog(id, now), this.opts);
|
|
1109
|
-
}
|
|
1110
|
-
catch (err) {
|
|
1111
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog read failed: ${msg(err)}`);
|
|
1112
|
-
}
|
|
1113
|
-
if (generation !== this.catalogGeneration(id)) {
|
|
1114
|
-
persisted = null;
|
|
1115
|
-
stale = undefined;
|
|
1116
|
-
}
|
|
1117
|
-
// Storage can yield while another request finishes a live refresh. Read
|
|
1118
|
-
// the shared cache again before an older manifest gets any authority.
|
|
1119
|
-
// The candidate with the later fresh deadline wins; both candidates have
|
|
1120
|
-
// already passed their own fingerprint and completeness checks.
|
|
1121
|
-
const reconciledAt = Date.now();
|
|
1122
|
-
const current = this.cache.get(id);
|
|
1123
|
-
const usableCurrent = current && current.staleUntil > reconciledAt ? current : undefined;
|
|
1124
|
-
if (usableCurrent) {
|
|
1125
|
-
stale = {
|
|
1126
|
-
tools: usableCurrent.tools,
|
|
1127
|
-
staleUntil: usableCurrent.staleUntil,
|
|
1128
|
-
};
|
|
1129
|
-
if (usableCurrent.exp > reconciledAt) {
|
|
1130
|
-
if (readOptions?.defer)
|
|
1131
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1132
|
-
return usableCurrent.tools;
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
if (persisted &&
|
|
1136
|
-
persisted.staleUntil > reconciledAt &&
|
|
1137
|
-
(!usableCurrent || persisted.expiresAt > usableCurrent.exp)) {
|
|
1138
|
-
this.cache.set(id, {
|
|
1139
|
-
tools: persisted.tools,
|
|
1140
|
-
fingerprint: persisted.fingerprint,
|
|
1141
|
-
exp: persisted.expiresAt,
|
|
1142
|
-
staleUntil: persisted.staleUntil,
|
|
1143
|
-
});
|
|
1144
|
-
if (persisted.expiresAt > reconciledAt) {
|
|
1145
|
-
if (readOptions?.defer)
|
|
1146
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1147
|
-
return persisted.tools;
|
|
1148
|
-
}
|
|
1149
|
-
stale = {
|
|
1150
|
-
tools: persisted.tools,
|
|
1151
|
-
staleUntil: persisted.staleUntil,
|
|
1152
|
-
};
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
if (stale &&
|
|
1156
|
-
stale.staleUntil > Date.now() &&
|
|
1157
|
-
readOptions?.defer &&
|
|
1158
|
-
requestGeneration === this.catalogGeneration(id) &&
|
|
1159
|
-
!this.invalidated.has(id)) {
|
|
1160
|
-
this.deferCatalogRefresh(id, baseUrl, requestGeneration, readOptions);
|
|
1161
|
-
// Invalidation can land synchronously while the refresh is attached.
|
|
1162
|
-
// Repeat the authority check at the exact stale publication point.
|
|
1163
|
-
if (stale.staleUntil > Date.now() &&
|
|
1164
|
-
requestGeneration === this.catalogGeneration(id) &&
|
|
1165
|
-
!this.invalidated.has(id)) {
|
|
1166
|
-
this.observeCatalogAccess(id, "stale");
|
|
1167
|
-
return stale.tools;
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
try {
|
|
1171
|
-
const tools = await this.refreshTools(id, baseUrl, requestScope, callOptions);
|
|
1172
|
-
if (readOptions?.defer)
|
|
1173
|
-
this.observeCatalogAccess(id, "fresh");
|
|
1174
|
-
return tools;
|
|
1175
|
-
}
|
|
1176
|
-
catch (err) {
|
|
1177
|
-
if (stale &&
|
|
1178
|
-
stale.staleUntil > Date.now() &&
|
|
1179
|
-
requestGeneration === this.catalogGeneration(id) &&
|
|
1180
|
-
!this.invalidated.has(id)) {
|
|
1181
|
-
if (readOptions?.defer)
|
|
1182
|
-
this.observeCatalogAccess(id, "stale");
|
|
1183
|
-
this.opts.logger.warn(`[connecta] connector "${id}" catalog refresh failed; serving stale catalog: ${msg(err)}`);
|
|
1184
|
-
return stale.tools;
|
|
1185
|
-
}
|
|
1186
|
-
throw err;
|
|
769
|
+
/** Construction-only view for the secret-free static config description. */
|
|
770
|
+
describeStaticTools(id) {
|
|
771
|
+
const tools = this.connectors.get(id)?.staticTools;
|
|
772
|
+
return tools?.map((tool) => this.publishUnreviewedTool(id, tool));
|
|
773
|
+
}
|
|
774
|
+
validateClassification(id, tools, overrides) {
|
|
775
|
+
const known = new Set(tools.map((tool) => tool.name));
|
|
776
|
+
for (const name of Object.keys(overrides ?? {})) {
|
|
777
|
+
if (!known.has(name))
|
|
778
|
+
throw new Error(`ConnectaConfig.classification: connector "${id}" has no tool "${name}"`);
|
|
1187
779
|
}
|
|
1188
780
|
}
|
|
1189
|
-
|
|
1190
|
-
* Coalesce one connector's catalog traversal inside one inbound request. The
|
|
1191
|
-
* WeakMap neither roots the request scope nor lets its connector context
|
|
1192
|
-
* escape into another request; settled entries are also removed eagerly.
|
|
1193
|
-
*
|
|
1194
|
-
* The read owns its signal, and each caller waits under its own: one that
|
|
1195
|
-
* is cancelled while others wait leaves with its own failure, and the read
|
|
1196
|
-
* is cancelled only when every caller has gone (src/runtime/shared-read.ts).
|
|
1197
|
-
*
|
|
1198
|
-
* The deployment-wide flight below this layer coalesces the actual live
|
|
1199
|
-
* refresh across requests. Its owner's context governs the listing, and its
|
|
1200
|
-
* owner's deadline bounds it; later callers join only its result, never its
|
|
1201
|
-
* request scope, and a failure that was the owner's alone sends them to a
|
|
1202
|
-
* fresh attempt rather than to them.
|
|
1203
|
-
*/
|
|
1204
|
-
async getTools(id, baseUrl, requestScope, callOptions = {}, readOptions) {
|
|
781
|
+
async getTools(id, baseUrl, requestScope, callOptions = {}, scope) {
|
|
1205
782
|
const connector = this.connectors.get(id);
|
|
1206
783
|
if (!connector)
|
|
1207
784
|
throw new Error(`Unknown connector "${id}"`);
|
|
1208
|
-
if (connector.staticTools)
|
|
1209
|
-
return connector.staticTools;
|
|
1210
785
|
if (!requestScope) {
|
|
1211
|
-
return this.loadTools(id, baseUrl, requestScope, callOptions,
|
|
786
|
+
return this.loadTools(id, baseUrl, requestScope, callOptions, scope);
|
|
1212
787
|
}
|
|
1213
788
|
let loads = this.requestCatalogLoads.get(requestScope);
|
|
1214
789
|
if (!loads) {
|
|
@@ -1216,19 +791,20 @@ export class Registry {
|
|
|
1216
791
|
this.requestCatalogLoads.set(requestScope, loads);
|
|
1217
792
|
}
|
|
1218
793
|
const requestLoads = loads;
|
|
1219
|
-
|
|
794
|
+
const key = JSON.stringify([id, scope?.principalKey, scope?.subjectKey, scope?.caller]);
|
|
795
|
+
let load = requestLoads.get(key);
|
|
1220
796
|
if (!load) {
|
|
1221
|
-
const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal },
|
|
797
|
+
const started = new SharedRead((signal) => this.loadTools(id, baseUrl, requestScope, { ...callOptions, signal }, scope),
|
|
1222
798
|
// Settled or cancelled, the read leaves the map before any caller
|
|
1223
799
|
// resumes, so the next one asks the caches afresh.
|
|
1224
800
|
() => {
|
|
1225
|
-
if (requestLoads.get(
|
|
1226
|
-
requestLoads.delete(
|
|
801
|
+
if (requestLoads.get(key) === started)
|
|
802
|
+
requestLoads.delete(key);
|
|
1227
803
|
if (requestLoads.size === 0) {
|
|
1228
804
|
this.requestCatalogLoads.delete(requestScope);
|
|
1229
805
|
}
|
|
1230
806
|
});
|
|
1231
|
-
requestLoads.set(
|
|
807
|
+
requestLoads.set(key, started);
|
|
1232
808
|
load = started;
|
|
1233
809
|
}
|
|
1234
810
|
return runEdge(load.join(callOptions.signal));
|
|
@@ -1244,16 +820,21 @@ export class Registry {
|
|
|
1244
820
|
return shape.state === "mismatch" ? shape.message : undefined;
|
|
1245
821
|
}
|
|
1246
822
|
catch (error) {
|
|
1247
|
-
this.opts.logger
|
|
823
|
+
logFailure(this.opts.logger, "credential shape read failed", failureRecord({ connector: id }, error));
|
|
1248
824
|
return undefined;
|
|
1249
825
|
}
|
|
1250
826
|
}
|
|
827
|
+
/** Age of a complete cache entry; reads never refresh it. */
|
|
828
|
+
catalogAgeMs(id) {
|
|
829
|
+
const observedAt = this.catalogObservedAt.get(id);
|
|
830
|
+
return observedAt === undefined ? null : Math.max(0, Date.now() - observedAt);
|
|
831
|
+
}
|
|
1251
832
|
/** Best-effort connector status for the operator UI. */
|
|
1252
|
-
async statusFor(id, baseUrl, requestScope = {}, callOptions = {}) {
|
|
833
|
+
async statusFor(id, baseUrl, requestScope = {}, callOptions = {}, scope) {
|
|
1253
834
|
const connector = this.connectors.get(id);
|
|
1254
835
|
if (!connector)
|
|
1255
|
-
return { state: "error", message: "Unknown connector" };
|
|
1256
|
-
const ctx = this.contextFor(id, baseUrl, requestScope, callOptions);
|
|
836
|
+
return ownStatus({ state: "error", message: "Unknown connector" });
|
|
837
|
+
const ctx = this.contextFor(id, baseUrl, requestScope, callOptions, scope);
|
|
1257
838
|
// Whatever the state turns out to be, it carries the drift the last
|
|
1258
839
|
// refresh saw. Reading it is a lookup, not a probe: a connector that has
|
|
1259
840
|
// listed nothing yet reports nothing, and status never lists on its own to
|
|
@@ -1262,56 +843,60 @@ export class Registry {
|
|
|
1262
843
|
// is third-party output, and status is read by the operator UI and copied
|
|
1263
844
|
// into responses.
|
|
1264
845
|
const withObservations = (status) => {
|
|
1265
|
-
const report =
|
|
846
|
+
const report = this.catalogDriftOf(connector);
|
|
1266
847
|
const access = this.catalogAccess.get(id);
|
|
1267
848
|
// Connector.status is an open plugin seam. Rebuild its public fields so
|
|
1268
849
|
// a connector cannot smuggle payload through either registry-owned
|
|
1269
|
-
// observation when this runtime has not made one
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
...(status.message !== undefined ? { message: status.message } : {}),
|
|
1273
|
-
};
|
|
1274
|
-
return {
|
|
1275
|
-
...boundedStatus,
|
|
850
|
+
// observation when this runtime has not made one, nor through its
|
|
851
|
+
// message, which survives only when connecta wrote it (INV-6).
|
|
852
|
+
return Object.assign(boundedStatus(status), {
|
|
1276
853
|
...(report ? { catalogDrift: report } : {}),
|
|
1277
854
|
...(access ? { catalogAccess: { ...access } } : {}),
|
|
1278
|
-
};
|
|
855
|
+
});
|
|
1279
856
|
};
|
|
857
|
+
// Credential declarations own their readiness even when a static catalog
|
|
858
|
+
// or a plugin status would otherwise report success. Never probe an empty slot.
|
|
859
|
+
if (connector.credential) {
|
|
860
|
+
try {
|
|
861
|
+
const values = (await this.opts.credentialVault?.getAll(id, this.opts.credentialOwner)) ?? null;
|
|
862
|
+
const shape = storedCredentialShape(connector.credential, values);
|
|
863
|
+
if (shape.state === "missing")
|
|
864
|
+
return withObservations(ownStatus({ state: "credential_required" }));
|
|
865
|
+
if (shape.state === "mismatch")
|
|
866
|
+
return withObservations(ownStatus({ state: "auth_required" }));
|
|
867
|
+
}
|
|
868
|
+
catch (err) {
|
|
869
|
+
return withObservations(failureStatus(id, err));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
1280
872
|
if (connector.status) {
|
|
1281
873
|
try {
|
|
1282
874
|
return withObservations(await connector.status(ctx));
|
|
1283
875
|
}
|
|
1284
876
|
catch (err) {
|
|
1285
|
-
return withObservations(
|
|
877
|
+
return withObservations(failureStatus(id, err));
|
|
1286
878
|
}
|
|
1287
879
|
}
|
|
1288
880
|
try {
|
|
1289
|
-
await this.getTools(id, baseUrl, requestScope, callOptions);
|
|
881
|
+
await this.getTools(id, baseUrl, requestScope, callOptions, scope);
|
|
1290
882
|
return withObservations({ state: "ok" });
|
|
1291
883
|
}
|
|
1292
884
|
catch (err) {
|
|
1293
|
-
return withObservations(
|
|
885
|
+
return withObservations(failureStatus(id, err));
|
|
1294
886
|
}
|
|
1295
887
|
}
|
|
1296
|
-
|
|
1297
|
-
this.advanceCatalogGeneration(id);
|
|
1298
|
-
this.cache.delete(id);
|
|
1299
|
-
this.invalidated.add(id);
|
|
1300
|
-
}
|
|
1301
|
-
deleteStoredCatalog(id) {
|
|
1302
|
-
return this.enqueueCatalogMutation(id, warnOnFailure(this.deleteCatalog(id), `connector "${id}" catalog invalidation failed`));
|
|
1303
|
-
}
|
|
1304
|
-
/** Drop a connector's cached tool list (e.g. after auth completes). */
|
|
888
|
+
/** Rotate the storage generation; in-flight old readers cannot republish it. */
|
|
1305
889
|
invalidate(id) {
|
|
1306
|
-
this.
|
|
1307
|
-
if (this.persistToolCatalog)
|
|
1308
|
-
void this.deleteStoredCatalog(id);
|
|
890
|
+
void this.invalidateStored(id);
|
|
1309
891
|
}
|
|
1310
|
-
/** Drop both in-memory and persisted tool catalogs. */
|
|
1311
892
|
async invalidateStored(id) {
|
|
1312
|
-
this.
|
|
1313
|
-
|
|
1314
|
-
await this.
|
|
893
|
+
this.catalogObservedAt.delete(id);
|
|
894
|
+
try {
|
|
895
|
+
await invalidateCatalogCache(this.opts.catalogStorage ?? this.opts.storage, id);
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
logFailure(this.opts.logger, "catalog invalidation failed", failureRecord({ connector: id }, error));
|
|
899
|
+
}
|
|
1315
900
|
}
|
|
1316
901
|
}
|
|
1317
902
|
class ScopedRegistryView {
|
|
@@ -1322,9 +907,7 @@ class ScopedRegistryView {
|
|
|
1322
907
|
/** An evicted view's admission controller must remain closed. */
|
|
1323
908
|
admissionPersonal;
|
|
1324
909
|
get personal() {
|
|
1325
|
-
return this.scope.principalKey
|
|
1326
|
-
? this.root.personalRegistry(this.scope.principalKey)
|
|
1327
|
-
: undefined;
|
|
910
|
+
return this.scope.principalKey ? this.root.personalRegistry(this.scope.principalKey) : undefined;
|
|
1328
911
|
}
|
|
1329
912
|
constructor(root, allowed, scope) {
|
|
1330
913
|
this.root = root;
|
|
@@ -1333,9 +916,7 @@ class ScopedRegistryView {
|
|
|
1333
916
|
this.maxResultBytes = root.maxResultBytes;
|
|
1334
917
|
// Check capacity at construction, then resolve the current registry on
|
|
1335
918
|
// use. A retained view must not revive one evicted while it was idle.
|
|
1336
|
-
this.admissionPersonal = scope.principalKey
|
|
1337
|
-
? root.personalRegistry(scope.principalKey)
|
|
1338
|
-
: undefined;
|
|
919
|
+
this.admissionPersonal = scope.principalKey ? root.personalRegistry(scope.principalKey) : undefined;
|
|
1339
920
|
}
|
|
1340
921
|
registryFor(id, admission = false) {
|
|
1341
922
|
if (!this.allowed.has(id))
|
|
@@ -1350,9 +931,17 @@ class ScopedRegistryView {
|
|
|
1350
931
|
listConnectors() {
|
|
1351
932
|
return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
|
|
1352
933
|
}
|
|
934
|
+
canReadConnectorSkills(id) {
|
|
935
|
+
return (this.registryFor(id) !== undefined && !this.scope.toolAccess?.has(id) && !this.scope.guardedToolAccess?.has(id));
|
|
936
|
+
}
|
|
1353
937
|
getConnector(id) {
|
|
1354
938
|
return this.registryFor(id)?.getConnector(id);
|
|
1355
939
|
}
|
|
940
|
+
getResourceConnector(id) {
|
|
941
|
+
if (this.scope.toolAccess?.has(id) || this.scope.guardedToolAccess?.has(id))
|
|
942
|
+
return undefined;
|
|
943
|
+
return this.getConnector(id);
|
|
944
|
+
}
|
|
1356
945
|
resolveAddress(address) {
|
|
1357
946
|
const parsed = splitAddress(address);
|
|
1358
947
|
if (!parsed)
|
|
@@ -1365,7 +954,7 @@ class ScopedRegistryView {
|
|
|
1365
954
|
if (!registry) {
|
|
1366
955
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1367
956
|
}
|
|
1368
|
-
const tools = await registry.getTools(
|
|
957
|
+
const tools = await registry.getTools(args[0], args[1], args[2], args[3], this.scope);
|
|
1369
958
|
const guarded = this.scope.guardedToolAccess?.get(args[0]);
|
|
1370
959
|
const granted = this.scope.toolAccess?.get(args[0]) ?? guarded;
|
|
1371
960
|
if (!granted)
|
|
@@ -1373,8 +962,7 @@ class ScopedRegistryView {
|
|
|
1373
962
|
// Every consumer — search, describe, call_tool, and a program's
|
|
1374
963
|
// connecta.call — resolves through this list, so an ungranted tool is
|
|
1375
964
|
// indistinguishable from one the connector never had.
|
|
1376
|
-
const visible = tools.filter((tool) => granted.has(tool.name) &&
|
|
1377
|
-
(!guarded?.has(tool.name) || isExplicitlyReadOnly(tool)));
|
|
965
|
+
const visible = tools.filter((tool) => granted.has(tool.name) && (!guarded?.has(tool.name) || tool.classification === "read"));
|
|
1378
966
|
if (visible.length < granted.size) {
|
|
1379
967
|
const present = new Set(tools.map((tool) => tool.name));
|
|
1380
968
|
for (const name of granted) {
|
|
@@ -1382,13 +970,13 @@ class ScopedRegistryView {
|
|
|
1382
970
|
this.root.noteAbsentGrant(args[0], name);
|
|
1383
971
|
}
|
|
1384
972
|
}
|
|
1385
|
-
return visible;
|
|
973
|
+
return carryCatalogFreshness(tools, visible);
|
|
1386
974
|
}
|
|
1387
975
|
contextFor(...args) {
|
|
1388
976
|
const registry = this.registryFor(args[0]);
|
|
1389
977
|
if (!registry)
|
|
1390
978
|
throw new Error(`Unknown connector "${args[0]}"`);
|
|
1391
|
-
return
|
|
979
|
+
return registry.contextFor(args[0], args[1], args[2], args[3], this.scope);
|
|
1392
980
|
}
|
|
1393
981
|
admitCall(...args) {
|
|
1394
982
|
const registry = this.registryFor(args[0], true);
|
|
@@ -1397,19 +985,26 @@ class ScopedRegistryView {
|
|
|
1397
985
|
}
|
|
1398
986
|
return registry.admitCall(...args);
|
|
1399
987
|
}
|
|
1400
|
-
stashResult(
|
|
1401
|
-
return this.root.stashResult(
|
|
988
|
+
stashResult(id, chunks, ttlSeconds) {
|
|
989
|
+
return this.root.stashResult(id, chunks, ttlSeconds, this.scope.subjectKey ? scopes.subject(this.scope.subjectKey) : scopes.results);
|
|
990
|
+
}
|
|
991
|
+
resultIdentity() {
|
|
992
|
+
return {
|
|
993
|
+
subject: this.scope.subjectKey ?? null,
|
|
994
|
+
principal: this.scope.principalKey ?? null,
|
|
995
|
+
endpoint: this.scope.endpoint ?? "/mcp",
|
|
996
|
+
origin: this.scope.origin ?? null,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
recheckResultAccess(address, classification, signal) {
|
|
1000
|
+
return this.scope.currentResultAccess?.(address, classification, signal) ?? Promise.resolve(true);
|
|
1402
1001
|
}
|
|
1403
1002
|
resultsStorage() {
|
|
1404
|
-
return this.scope.subjectKey
|
|
1405
|
-
? this.root.scopedStorage(this.scope.subjectKey)
|
|
1406
|
-
: this.root.resultsStorage();
|
|
1003
|
+
return this.scope.subjectKey ? this.root.scopedStorage(this.scope.subjectKey) : this.root.resultsStorage();
|
|
1407
1004
|
}
|
|
1408
1005
|
credentialDriftFor(id) {
|
|
1409
1006
|
const registry = this.registryFor(id);
|
|
1410
|
-
return registry
|
|
1411
|
-
? registry.credentialDriftFor(id)
|
|
1412
|
-
: Promise.resolve(undefined);
|
|
1007
|
+
return registry ? registry.credentialDriftFor(id) : Promise.resolve(undefined);
|
|
1413
1008
|
}
|
|
1414
1009
|
observedOutputSchema(connectorId, definition) {
|
|
1415
1010
|
return this.registryFor(connectorId)?.observedOutputSchema(connectorId, definition);
|
|
@@ -1417,30 +1012,23 @@ class ScopedRegistryView {
|
|
|
1417
1012
|
observeOutputShape(connectorId, definition, value) {
|
|
1418
1013
|
this.registryFor(connectorId)?.observeOutputShape(connectorId, definition, value);
|
|
1419
1014
|
}
|
|
1015
|
+
catalogAgeMs(id) {
|
|
1016
|
+
return this.registryFor(id)?.catalogAgeMs(id) ?? null;
|
|
1017
|
+
}
|
|
1420
1018
|
statusFor(...args) {
|
|
1421
1019
|
const registry = this.registryFor(args[0]);
|
|
1422
1020
|
return registry
|
|
1423
|
-
? registry.statusFor(
|
|
1021
|
+
? registry.statusFor(args[0], args[1], args[2], args[3], this.scope)
|
|
1424
1022
|
: Promise.resolve({ state: "error", message: "Unknown connector" });
|
|
1425
1023
|
}
|
|
1426
1024
|
invalidateStored(id) {
|
|
1427
1025
|
const registry = this.registryFor(id);
|
|
1428
1026
|
return registry ? registry.invalidateStored(id) : Promise.resolve();
|
|
1429
1027
|
}
|
|
1430
|
-
credentialUiAvailable() {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
let state = null;
|
|
1436
|
-
try {
|
|
1437
|
-
state = new URL(authorizationUrl).searchParams.get("state");
|
|
1438
|
-
}
|
|
1439
|
-
catch {
|
|
1440
|
-
return;
|
|
1441
|
-
}
|
|
1442
|
-
if (state) {
|
|
1443
|
-
await this.root.storeOAuthHandoff(id, state, this.scope.principalKey);
|
|
1444
|
-
}
|
|
1028
|
+
credentialUiAvailable() {
|
|
1029
|
+
return this.root.credentialUiAvailable();
|
|
1030
|
+
}
|
|
1031
|
+
async bindOAuthHandoff(id, authorizationUrl, principalKey) {
|
|
1032
|
+
await this.root.bindOAuthHandoff(id, authorizationUrl, principalKey ?? this.scope.principalKey);
|
|
1445
1033
|
}
|
|
1446
1034
|
}
|