@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
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import { MAX_CACHE_TTL_MS, specTypeSchemas } from "@modelcontextprotocol/client";
|
|
2
|
+
import { callerOf } from "./connector-caller.js";
|
|
3
|
+
import { CONFIG_DEFAULTS } from "./config-defaults.js";
|
|
4
|
+
import { ConnectorCallError } from "./errors.js";
|
|
5
|
+
import { fingerprintSerializedCatalog } from "./catalog-fingerprint.js";
|
|
6
|
+
import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTES } from "./catalog-limits.js";
|
|
7
|
+
import { redactCatalog, sentSecretsForRequest } from "./sent-secrets.js";
|
|
8
|
+
import { failureRecord, logFailure } from "./operator-record.js";
|
|
9
|
+
import { responseCacheKeys } from "./storage/keys.js";
|
|
10
|
+
/** Publish one accepted catalog refresh, with its partitioned drift baseline. */
|
|
11
|
+
export async function observeCompletedCatalogRefresh(ctx, refresh) {
|
|
12
|
+
await settings.get(ctx)?.onCompletedCatalogRefresh?.(refresh, ctx);
|
|
13
|
+
}
|
|
14
|
+
/** Hash-only facts preserve change counts without storing tool names or schemas. */
|
|
15
|
+
async function catalogFingerprints(tools) {
|
|
16
|
+
const facts = [];
|
|
17
|
+
for (let offset = 0; offset < tools.length; offset += 128) {
|
|
18
|
+
facts.push(...(await Promise.all(tools.slice(offset, offset + 128).map(async (tool) => ({
|
|
19
|
+
name: (await fingerprintSerializedCatalog(tool.name)).fingerprint,
|
|
20
|
+
fact: (await fingerprintSerializedCatalog(JSON.stringify(tool))).fingerprint,
|
|
21
|
+
})))));
|
|
22
|
+
}
|
|
23
|
+
return facts;
|
|
24
|
+
}
|
|
25
|
+
async function refreshBaseline(storage, key, raw, io) {
|
|
26
|
+
if (!raw)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(raw);
|
|
30
|
+
const hash = (v) => typeof v === "string" && /^sha256:[0-9]+:[0-9a-f]{64}$/.test(v);
|
|
31
|
+
if (!hash(value.digest) ||
|
|
32
|
+
!hash(value.fingerprint) ||
|
|
33
|
+
!/^[0-9a-f-]{36}$/.test(value.revision) ||
|
|
34
|
+
!Number.isInteger(value.chunkCount) ||
|
|
35
|
+
value.chunkCount < 1 ||
|
|
36
|
+
value.chunkCount > Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES))
|
|
37
|
+
return;
|
|
38
|
+
const chunks = [];
|
|
39
|
+
for (let index = 0; index < value.chunkCount; index++) {
|
|
40
|
+
const chunk = await io(() => storage.get(responseCacheKeys.chunk(key, value.revision, index)));
|
|
41
|
+
if (chunk === null || new TextEncoder().encode(chunk).byteLength > MAX_CATALOG_CHUNK_BYTES)
|
|
42
|
+
return;
|
|
43
|
+
chunks.push(chunk);
|
|
44
|
+
}
|
|
45
|
+
const serialized = chunks.join("");
|
|
46
|
+
if ((await fingerprintSerializedCatalog(serialized)).fingerprint !== value.fingerprint)
|
|
47
|
+
return;
|
|
48
|
+
const tools = JSON.parse(serialized);
|
|
49
|
+
if (Array.isArray(tools) &&
|
|
50
|
+
tools.length <= MAX_CATALOG_TOOLS &&
|
|
51
|
+
tools.every((tool) => tool && hash(tool.name) && hash(tool.fact)))
|
|
52
|
+
return { digest: value.digest, tools };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* Retired or torn baselines establish a new count baseline. */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function acceptCatalogRefresh(storage, key, connectorId, digest, tools, privateCatalog, current, io) {
|
|
59
|
+
const next = await catalogFingerprints(tools);
|
|
60
|
+
const serialized = JSON.stringify(next);
|
|
61
|
+
const revision = crypto.randomUUID();
|
|
62
|
+
const fingerprint = (await fingerprintSerializedCatalog(serialized)).fingerprint;
|
|
63
|
+
// Fingerprints are ASCII. Chunk them under the same platform value ceiling
|
|
64
|
+
// as catalogs, then publish their manifest in one compare-and-set.
|
|
65
|
+
const chunkCount = Math.ceil(serialized.length / MAX_CATALOG_CHUNK_BYTES);
|
|
66
|
+
for (let index = 0; index < chunkCount; index++) {
|
|
67
|
+
if (!(await current()))
|
|
68
|
+
return;
|
|
69
|
+
const chunk = serialized.slice(index * MAX_CATALOG_CHUNK_BYTES, (index + 1) * MAX_CATALOG_CHUNK_BYTES);
|
|
70
|
+
await io(() => storage.set(responseCacheKeys.chunk(key, revision, index), chunk, { ttlSeconds: GENERATION_TTL_SECONDS + 300 }));
|
|
71
|
+
}
|
|
72
|
+
const manifest = { digest, revision, chunkCount, fingerprint };
|
|
73
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
74
|
+
if (!(await current()))
|
|
75
|
+
return;
|
|
76
|
+
const raw = await io(() => storage.get(key));
|
|
77
|
+
const previous = await refreshBaseline(storage, key, raw, io);
|
|
78
|
+
if (!(await current()))
|
|
79
|
+
return;
|
|
80
|
+
const published = await io(() => storage.compareAndSet(key, raw, JSON.stringify(manifest), { ttlSeconds: GENERATION_TTL_SECONDS }));
|
|
81
|
+
if (!(await current()))
|
|
82
|
+
return;
|
|
83
|
+
if (published)
|
|
84
|
+
return {
|
|
85
|
+
connectorId,
|
|
86
|
+
digest,
|
|
87
|
+
next,
|
|
88
|
+
private: privateCatalog,
|
|
89
|
+
...(previous ? { previousDigest: previous.digest, previous: previous.tools } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
throw new Error("Catalog refresh baseline is busy.");
|
|
93
|
+
}
|
|
94
|
+
/** Custom dynamic connectors have no SDK cache; publish their complete intake here. */
|
|
95
|
+
export async function observeUncachedCatalogRefresh(ctx, connectorId, tools) {
|
|
96
|
+
const policy = settings.get(ctx);
|
|
97
|
+
if (!policy?.onCompletedCatalogRefresh)
|
|
98
|
+
return;
|
|
99
|
+
const config = (await fingerprintSerializedCatalog("custom-connector")).fingerprint;
|
|
100
|
+
const partition = (await fingerprintSerializedCatalog(JSON.stringify([policy.partition, ctx.baseUrl, ctx.publicUrl])))
|
|
101
|
+
.fingerprint;
|
|
102
|
+
const digest = (await fingerprintSerializedCatalog(JSON.stringify(tools))).fingerprint;
|
|
103
|
+
const refresh = await acceptCatalogRefresh(policy.storage, responseCacheKeys.refreshDigest(connectorId, config, partition), connectorId, digest, tools, true, async () => !ctx.signal?.aborted, (read) => read());
|
|
104
|
+
if (refresh)
|
|
105
|
+
await observeCompletedCatalogRefresh(ctx, refresh);
|
|
106
|
+
}
|
|
107
|
+
export function catalogItems(method, result) {
|
|
108
|
+
if (method === "tools/list")
|
|
109
|
+
return result.tools;
|
|
110
|
+
if (method === "resources/list")
|
|
111
|
+
return result.resources;
|
|
112
|
+
return result.resourceTemplates;
|
|
113
|
+
}
|
|
114
|
+
function catalogSchema(method) {
|
|
115
|
+
return method === "tools/list"
|
|
116
|
+
? specTypeSchemas.ListToolsResult
|
|
117
|
+
: method === "resources/list"
|
|
118
|
+
? specTypeSchemas.ListResourcesResult
|
|
119
|
+
: specTypeSchemas.ListResourceTemplatesResult;
|
|
120
|
+
}
|
|
121
|
+
const expiry = new WeakMap();
|
|
122
|
+
const fetches = new WeakMap();
|
|
123
|
+
export function observeCatalogFetch(ctx, at, method = "tools/list") {
|
|
124
|
+
const methods = fetches.get(ctx) ?? new Map();
|
|
125
|
+
methods.set(method, at);
|
|
126
|
+
fetches.set(ctx, methods);
|
|
127
|
+
}
|
|
128
|
+
export function catalogFetchedAt(ctx, method = "tools/list") {
|
|
129
|
+
return fetches.get(ctx)?.get(method);
|
|
130
|
+
}
|
|
131
|
+
export function catalogExpiry(ctx, method = "tools/list") {
|
|
132
|
+
return expiry.get(ctx)?.get(method);
|
|
133
|
+
}
|
|
134
|
+
export function observeCatalogExpiry(ctx, method, ttlMs, expiresAt, staleFallback = false) {
|
|
135
|
+
const fetchedAt = catalogFetchedAt(ctx, method) ?? Date.now();
|
|
136
|
+
const methods = expiry.get(ctx) ?? new Map();
|
|
137
|
+
methods.set(method, {
|
|
138
|
+
fetchedAt,
|
|
139
|
+
ttlMs,
|
|
140
|
+
expiresAt: Math.min(expiresAt ?? Infinity, fetchedAt + ttlMs),
|
|
141
|
+
staleFallback,
|
|
142
|
+
});
|
|
143
|
+
expiry.set(ctx, methods);
|
|
144
|
+
}
|
|
145
|
+
// Tool facts remain available for ordinary calls after a transient listing
|
|
146
|
+
// failure for five minutes. Resources never use stale inventories. A fallback
|
|
147
|
+
// carries its original expired deadline and cannot authorize auth recovery.
|
|
148
|
+
const STALE_FALLBACK_MS = 300_000;
|
|
149
|
+
const settings = new WeakMap();
|
|
150
|
+
export function attachCatalogCache(ctx, value) {
|
|
151
|
+
settings.set(ctx, value);
|
|
152
|
+
}
|
|
153
|
+
const defaultOwner = {};
|
|
154
|
+
const scopes = new WeakMap();
|
|
155
|
+
function cacheScope(ctx, connectorId, owner = defaultOwner) {
|
|
156
|
+
const key = ctx.requestScope ?? ctx;
|
|
157
|
+
let connectors = scopes.get(key);
|
|
158
|
+
if (!connectors) {
|
|
159
|
+
connectors = new Map();
|
|
160
|
+
scopes.set(key, connectors);
|
|
161
|
+
}
|
|
162
|
+
let owners = connectors.get(connectorId);
|
|
163
|
+
if (!owners) {
|
|
164
|
+
owners = new WeakMap();
|
|
165
|
+
connectors.set(connectorId, owners);
|
|
166
|
+
}
|
|
167
|
+
let scope = owners.get(owner);
|
|
168
|
+
if (!scope) {
|
|
169
|
+
scope = { closed: false, abort: new AbortController() };
|
|
170
|
+
owners.set(owner, scope);
|
|
171
|
+
}
|
|
172
|
+
return scope;
|
|
173
|
+
}
|
|
174
|
+
/** Start the fence before SDK eviction; teardown joins already-started I/O. */
|
|
175
|
+
export function observeCatalogChange(ctx, connectorId, connectionSignal, owner) {
|
|
176
|
+
const scope = cacheScope(ctx, connectorId, owner);
|
|
177
|
+
if (scope.closed || connectionSignal?.aborted)
|
|
178
|
+
return;
|
|
179
|
+
const writing = invalidateCatalogCache(settings.get(ctx)?.storage ?? ctx.storage, connectorId).catch((error) => {
|
|
180
|
+
logFailure(ctx.logger, "catalog invalidation failed", failureRecord({ connector: connectorId }, error));
|
|
181
|
+
});
|
|
182
|
+
scope.invalidating = scope.invalidating ? Promise.all([scope.invalidating, writing]).then(() => { }) : writing;
|
|
183
|
+
}
|
|
184
|
+
export function closeCatalogCacheScope(ctx, connectorId, owner) {
|
|
185
|
+
const scope = cacheScope(ctx, connectorId, owner);
|
|
186
|
+
scope.closed = true;
|
|
187
|
+
scope.abort.abort();
|
|
188
|
+
return scope.invalidating;
|
|
189
|
+
}
|
|
190
|
+
/** Zero is an explicit refusal to reuse. Positive hints use the configured bounds. */
|
|
191
|
+
function catalogTtlMs(ctx, hint) {
|
|
192
|
+
const policy = settings.get(ctx);
|
|
193
|
+
const ttl = hint === undefined ? Math.floor(policy?.defaultTtlMs ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds * 1000) : hint;
|
|
194
|
+
if (typeof ttl !== "number" || !Number.isSafeInteger(ttl) || ttl <= 0)
|
|
195
|
+
return 0;
|
|
196
|
+
return Math.floor(Math.min(Math.max(ttl, policy?.minTtlMs ?? 0), policy?.maxTtlMs ?? MAX_CACHE_TTL_MS, MAX_CACHE_TTL_MS));
|
|
197
|
+
}
|
|
198
|
+
/** Sanitize before SDK automatic writes and before its derived tool index. */
|
|
199
|
+
export function catalogIntake(ctx, result) {
|
|
200
|
+
const tools = redactCatalog(ctx, result.tools).map(({ _meta: _ignored, icons, ...tool }) => ({
|
|
201
|
+
...tool,
|
|
202
|
+
...(icons ? { icons: icons.filter((icon) => !/^data:/i.test(icon.src)) } : {}),
|
|
203
|
+
}));
|
|
204
|
+
if (tools.length > MAX_CATALOG_TOOLS)
|
|
205
|
+
throw catalogCeiling();
|
|
206
|
+
const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redact({ ...result, tools });
|
|
207
|
+
if (new TextEncoder().encode(JSON.stringify(clean)).byteLength > MAX_SERIALIZED_CATALOG_BYTES)
|
|
208
|
+
throw catalogCeiling();
|
|
209
|
+
for (const tool of clean.tools)
|
|
210
|
+
assertHeaderDeclarations(tool.inputSchema);
|
|
211
|
+
return {
|
|
212
|
+
...clean,
|
|
213
|
+
ttlMs: catalogTtlMs(ctx, result.ttlMs),
|
|
214
|
+
cacheScope: result.cacheScope === "public" ? "public" : "private",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** Refuse before SDK 2.3.1's header exclusion path logs downstream names/reasons.
|
|
218
|
+
* Match its schema keyword walk; errors carry no downstream text (INV-6/8). */
|
|
219
|
+
function assertHeaderDeclarations(schema) {
|
|
220
|
+
const names = new Set();
|
|
221
|
+
const maps = new Set(["patternProperties", "dependentSchemas", "$defs", "definitions"]);
|
|
222
|
+
const branches = [
|
|
223
|
+
"items",
|
|
224
|
+
"prefixItems",
|
|
225
|
+
"contains",
|
|
226
|
+
"additionalProperties",
|
|
227
|
+
"unevaluatedProperties",
|
|
228
|
+
"unevaluatedItems",
|
|
229
|
+
"propertyNames",
|
|
230
|
+
...maps,
|
|
231
|
+
"oneOf",
|
|
232
|
+
"anyOf",
|
|
233
|
+
"allOf",
|
|
234
|
+
"not",
|
|
235
|
+
"if",
|
|
236
|
+
"then",
|
|
237
|
+
"else",
|
|
238
|
+
];
|
|
239
|
+
const invalid = () => {
|
|
240
|
+
throw new ConnectorCallError("connector_call_failed", "Downstream tool has an invalid x-mcp-header declaration.", {
|
|
241
|
+
retryable: false,
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
const visit = (node, reachable, property) => {
|
|
245
|
+
if (!node || typeof node !== "object")
|
|
246
|
+
return;
|
|
247
|
+
const value = node;
|
|
248
|
+
if ("x-mcp-header" in value) {
|
|
249
|
+
const header = value["x-mcp-header"];
|
|
250
|
+
if (!reachable ||
|
|
251
|
+
!property ||
|
|
252
|
+
typeof header !== "string" ||
|
|
253
|
+
!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(header) ||
|
|
254
|
+
!["string", "integer", "boolean", "number"].includes(value.type) ||
|
|
255
|
+
names.has(header.toLowerCase()))
|
|
256
|
+
invalid();
|
|
257
|
+
names.add(header.toLowerCase());
|
|
258
|
+
}
|
|
259
|
+
if (value.properties && typeof value.properties === "object") {
|
|
260
|
+
for (const child of Object.values(value.properties))
|
|
261
|
+
visit(child, reachable, true);
|
|
262
|
+
}
|
|
263
|
+
for (const keyword of branches) {
|
|
264
|
+
const sub = value[keyword];
|
|
265
|
+
const children = Array.isArray(sub)
|
|
266
|
+
? sub
|
|
267
|
+
: sub && typeof sub === "object" && maps.has(keyword)
|
|
268
|
+
? Object.values(sub)
|
|
269
|
+
: [sub];
|
|
270
|
+
for (const child of children)
|
|
271
|
+
visit(child, false, false);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
visit(schema, true, false);
|
|
275
|
+
}
|
|
276
|
+
function catalogCeiling() {
|
|
277
|
+
return new ConnectorCallError("connector_call_failed", "Downstream catalog exceeds the complete-catalog ceiling.", {
|
|
278
|
+
retryable: false,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const GENERATION_TTL_SECONDS = (MAX_CACHE_TTL_MS * 2) / 1000;
|
|
282
|
+
export async function invalidateCatalogCache(storage, connectorId) {
|
|
283
|
+
await storage.set(responseCacheKeys.generation(connectorId), crypto.randomUUID(), {
|
|
284
|
+
ttlSeconds: GENERATION_TTL_SECONDS,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/** Keys ignore self-reported serverInfo and bind the configured connector instead. */
|
|
288
|
+
export async function catalogClientOptions(ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity) {
|
|
289
|
+
const policy = settings.get(ctx);
|
|
290
|
+
const partition = JSON.stringify([
|
|
291
|
+
policy?.partition ?? JSON.stringify(callerOf(ctx) ?? null),
|
|
292
|
+
ctx.baseUrl,
|
|
293
|
+
ctx.publicUrl,
|
|
294
|
+
authPartition,
|
|
295
|
+
]);
|
|
296
|
+
const sharedPartition = JSON.stringify([
|
|
297
|
+
"host-shared",
|
|
298
|
+
policy?.sharedPartition ?? JSON.stringify([ctx.baseUrl, ctx.publicUrl, callerOf(ctx)?.pool]),
|
|
299
|
+
authPartition,
|
|
300
|
+
]);
|
|
301
|
+
const storage = policy?.storage ?? ctx.storage;
|
|
302
|
+
const configHash = (await fingerprintSerializedCatalog(JSON.stringify([
|
|
303
|
+
config,
|
|
304
|
+
policy?.defaultTtlMs ?? 300_000,
|
|
305
|
+
policy?.minTtlMs ?? 0,
|
|
306
|
+
policy?.maxTtlMs ?? MAX_CACHE_TTL_MS,
|
|
307
|
+
]))).fingerprint;
|
|
308
|
+
const scope = cacheScope(ctx, connectorId, owner);
|
|
309
|
+
let unavailable = false;
|
|
310
|
+
const connectionGeneration = {};
|
|
311
|
+
let active;
|
|
312
|
+
// ResponseCacheStore has no per-call options. A method snapshots its binding
|
|
313
|
+
// before any await; an abandoned storage promise never adopts a later listing.
|
|
314
|
+
const capture = (listing = active) => {
|
|
315
|
+
const signals = [scope.abort.signal, connectionSignal, listing?.ctx.signal].filter((signal) => signal !== undefined);
|
|
316
|
+
const ended = () => scope.closed || listing?.ended === true || signals.some((signal) => signal.aborted);
|
|
317
|
+
const reason = () => signals.find((signal) => signal.aborted)?.reason ??
|
|
318
|
+
new ConnectorCallError("connector_call_failed", "Catalog cache operation ended.");
|
|
319
|
+
return {
|
|
320
|
+
ctx: listing?.ctx ?? ctx,
|
|
321
|
+
generation: listing?.generation ?? { ...connectionGeneration },
|
|
322
|
+
credentialIdentity: listing?.credentialIdentity,
|
|
323
|
+
listing,
|
|
324
|
+
stopped: () => unavailable || ended(),
|
|
325
|
+
io: (read) => new Promise((resolve, reject) => {
|
|
326
|
+
const cleanup = () => {
|
|
327
|
+
for (const signal of signals)
|
|
328
|
+
signal.removeEventListener("abort", abort);
|
|
329
|
+
};
|
|
330
|
+
const abort = () => {
|
|
331
|
+
cleanup();
|
|
332
|
+
reject(reason());
|
|
333
|
+
};
|
|
334
|
+
if (ended()) {
|
|
335
|
+
abort();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
for (const signal of signals)
|
|
339
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
340
|
+
// Storage cannot cancel dispatched I/O. Stop waiting, but attach both
|
|
341
|
+
// outcomes so its eventual completion cannot continue cache publication.
|
|
342
|
+
try {
|
|
343
|
+
read().then((value) => {
|
|
344
|
+
cleanup();
|
|
345
|
+
resolve(value);
|
|
346
|
+
}, (error) => {
|
|
347
|
+
cleanup();
|
|
348
|
+
reject(error);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
cleanup();
|
|
353
|
+
reject(error);
|
|
354
|
+
}
|
|
355
|
+
}),
|
|
356
|
+
};
|
|
357
|
+
};
|
|
358
|
+
let lastStamp = 0;
|
|
359
|
+
let staleTools;
|
|
360
|
+
const currentGeneration = async ({ stopped, io, generation }) => {
|
|
361
|
+
const key = responseCacheKeys.generation(connectorId);
|
|
362
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
363
|
+
if (stopped())
|
|
364
|
+
return undefined;
|
|
365
|
+
const current = await io(() => storage.get(key));
|
|
366
|
+
if (stopped())
|
|
367
|
+
return undefined;
|
|
368
|
+
if (current)
|
|
369
|
+
return current;
|
|
370
|
+
const next = generation.value ?? crypto.randomUUID();
|
|
371
|
+
const claimed = await io(() => storage.compareAndSet(key, null, next, { ttlSeconds: GENERATION_TTL_SECONDS }));
|
|
372
|
+
if (stopped())
|
|
373
|
+
return undefined;
|
|
374
|
+
if (claimed)
|
|
375
|
+
return next;
|
|
376
|
+
}
|
|
377
|
+
throw new Error("Catalog cache generation is busy.");
|
|
378
|
+
};
|
|
379
|
+
const namespace = async (operation) => {
|
|
380
|
+
const { stopped, generation, io } = operation;
|
|
381
|
+
if (resolveCredentialIdentity && (await io(resolveCredentialIdentity)) !== operation.credentialIdentity)
|
|
382
|
+
return undefined;
|
|
383
|
+
const current = await currentGeneration(operation);
|
|
384
|
+
if (!current || stopped())
|
|
385
|
+
return undefined;
|
|
386
|
+
generation.value ??= current;
|
|
387
|
+
return generation.value === current
|
|
388
|
+
? responseCacheKeys.namespace(connectorId, configHash, generation.value)
|
|
389
|
+
: undefined;
|
|
390
|
+
};
|
|
391
|
+
const address = async (key, operation) => {
|
|
392
|
+
const { stopped } = operation;
|
|
393
|
+
if (stopped() || !isCatalogMethod(key.method) || key.params)
|
|
394
|
+
return undefined;
|
|
395
|
+
let pair;
|
|
396
|
+
try {
|
|
397
|
+
pair = JSON.parse(key.partition ?? "");
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
if (!Array.isArray(pair) ||
|
|
403
|
+
pair.length !== 2 ||
|
|
404
|
+
(pair[1] !== partition && (pair[1] !== "" || sharing !== "shared")))
|
|
405
|
+
return undefined;
|
|
406
|
+
const prefix = await namespace(operation);
|
|
407
|
+
if (!prefix || stopped())
|
|
408
|
+
return undefined;
|
|
409
|
+
// Hash the admitted principal/pool/auth partition; no identities or server text in keys.
|
|
410
|
+
const digest = (await fingerprintSerializedCatalog(JSON.stringify([
|
|
411
|
+
key.method,
|
|
412
|
+
pair[1] === "" ? sharedPartition : partition,
|
|
413
|
+
operation.credentialIdentity ?? null,
|
|
414
|
+
]))).fingerprint;
|
|
415
|
+
return stopped() ? undefined : responseCacheKeys.entry(prefix, digest);
|
|
416
|
+
};
|
|
417
|
+
const manifest = (raw) => {
|
|
418
|
+
if (!raw)
|
|
419
|
+
return undefined;
|
|
420
|
+
try {
|
|
421
|
+
const m = JSON.parse(raw);
|
|
422
|
+
return Number.isSafeInteger(m.stamp) &&
|
|
423
|
+
m.stamp > 0 &&
|
|
424
|
+
Number.isFinite(m.expiresAt) &&
|
|
425
|
+
Number.isFinite(m.fetchedAt) &&
|
|
426
|
+
m.fetchedAt >= 0 &&
|
|
427
|
+
m.fetchedAt <= m.expiresAt &&
|
|
428
|
+
(m.scope === "public" || m.scope === "private") &&
|
|
429
|
+
typeof m.revision === "string" &&
|
|
430
|
+
/^[0-9a-f-]{36}$/.test(m.revision) &&
|
|
431
|
+
Number.isInteger(m.chunkCount) &&
|
|
432
|
+
m.chunkCount > 0 &&
|
|
433
|
+
m.chunkCount <= Math.ceil(MAX_SERIALIZED_CATALOG_BYTES / MAX_CATALOG_CHUNK_BYTES) + 1 &&
|
|
434
|
+
/^sha256:[0-9]+:[0-9a-f]{64}$/.test(m.fingerprint)
|
|
435
|
+
? m
|
|
436
|
+
: undefined;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const intake = (ctx, method, result) => {
|
|
443
|
+
const body = sharing === "private" ? { ...result, cacheScope: "private" } : result;
|
|
444
|
+
if (method === "tools/list")
|
|
445
|
+
return catalogIntake(ctx, body);
|
|
446
|
+
const field = method === "resources/list" ? "resources" : "resourceTemplates";
|
|
447
|
+
const items = catalogItems(method, body).map(({ _meta: _ignored, ...item }) => item);
|
|
448
|
+
if (items.length > MAX_CATALOG_TOOLS)
|
|
449
|
+
throw catalogCeiling();
|
|
450
|
+
const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redact({ ...body, [field]: items });
|
|
451
|
+
if (new TextEncoder().encode(JSON.stringify(clean)).byteLength > MAX_SERIALIZED_CATALOG_BYTES)
|
|
452
|
+
throw catalogCeiling();
|
|
453
|
+
return {
|
|
454
|
+
...clean,
|
|
455
|
+
ttlMs: catalogTtlMs(ctx, body.ttlMs),
|
|
456
|
+
cacheScope: body.cacheScope === "public" ? "public" : "private",
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
const completeCatalogRefresh = async (result) => {
|
|
460
|
+
const listing = active;
|
|
461
|
+
const operation = capture();
|
|
462
|
+
const { stopped, io } = operation;
|
|
463
|
+
if (!listing || stopped())
|
|
464
|
+
return;
|
|
465
|
+
const clean = intake(listing.ctx, "tools/list", result);
|
|
466
|
+
const digest = (await fingerprintSerializedCatalog(JSON.stringify(clean))).fingerprint;
|
|
467
|
+
const partitionDigest = (await fingerprintSerializedCatalog(JSON.stringify([
|
|
468
|
+
clean.cacheScope === "public" ? sharedPartition : partition,
|
|
469
|
+
operation.credentialIdentity ?? null,
|
|
470
|
+
]))).fingerprint;
|
|
471
|
+
try {
|
|
472
|
+
return await acceptCatalogRefresh(storage, responseCacheKeys.refreshDigest(connectorId, configHash, partitionDigest), connectorId, digest, clean.tools, clean.cacheScope !== "public", async () => !stopped() && !!(await namespace(operation)) && !stopped(), io);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
if (!stopped())
|
|
476
|
+
logFailure(listing.ctx.logger, "catalog refresh observation failed", failureRecord({ connector: connectorId }, error));
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const store = {
|
|
480
|
+
async get(key) {
|
|
481
|
+
const operation = capture();
|
|
482
|
+
const { ctx, stopped, io } = operation;
|
|
483
|
+
const root = await address(key, operation);
|
|
484
|
+
if (!root || stopped())
|
|
485
|
+
return undefined;
|
|
486
|
+
const m = manifest(await io(() => storage.get(root)));
|
|
487
|
+
if (stopped() ||
|
|
488
|
+
!m ||
|
|
489
|
+
(m.expiresAt <= Date.now() && (key.method !== "tools/list" || m.expiresAt + STALE_FALLBACK_MS <= Date.now())))
|
|
490
|
+
return undefined;
|
|
491
|
+
const chunks = [];
|
|
492
|
+
for (let i = 0; i < m.chunkCount; i++) {
|
|
493
|
+
if (stopped())
|
|
494
|
+
return undefined;
|
|
495
|
+
const chunk = await io(() => storage.get(responseCacheKeys.chunk(root, m.revision, i)));
|
|
496
|
+
if (stopped() || chunk === null || new TextEncoder().encode(chunk).byteLength > MAX_CATALOG_CHUNK_BYTES)
|
|
497
|
+
return undefined;
|
|
498
|
+
chunks.push(chunk);
|
|
499
|
+
}
|
|
500
|
+
const value = chunks.join("");
|
|
501
|
+
const fingerprint = await fingerprintSerializedCatalog(value);
|
|
502
|
+
if (stopped() ||
|
|
503
|
+
fingerprint.byteLength > MAX_SERIALIZED_CATALOG_BYTES ||
|
|
504
|
+
fingerprint.fingerprint !== m.fingerprint ||
|
|
505
|
+
!(await namespace(operation)) ||
|
|
506
|
+
stopped())
|
|
507
|
+
return undefined;
|
|
508
|
+
const result = JSON.parse(value);
|
|
509
|
+
const method = key.method;
|
|
510
|
+
const items = catalogItems(method, result);
|
|
511
|
+
if (!Array.isArray(items) ||
|
|
512
|
+
items.length > MAX_CATALOG_TOOLS ||
|
|
513
|
+
"nextCursor" in result ||
|
|
514
|
+
(result.resultType !== undefined && result.resultType !== "complete"))
|
|
515
|
+
return undefined;
|
|
516
|
+
const validated = await catalogSchema(method)["~standard"].validate(result);
|
|
517
|
+
if (stopped() || validated.issues)
|
|
518
|
+
return undefined;
|
|
519
|
+
const clean = intake(ctx, method, validated.value);
|
|
520
|
+
if (m.expiresAt <= Date.now()) {
|
|
521
|
+
if (method === "tools/list" && (!staleTools || staleTools.manifest.fetchedAt < m.fetchedAt))
|
|
522
|
+
staleTools = { result: clean, manifest: m };
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
observeCatalogFetch(ctx, m.fetchedAt, method);
|
|
526
|
+
observeCatalogExpiry(ctx, method, clean.ttlMs, m.expiresAt);
|
|
527
|
+
return { value: JSON.stringify(clean), stamp: m.stamp, expiresAt: m.expiresAt, scope: m.scope };
|
|
528
|
+
},
|
|
529
|
+
async set(key, entry) {
|
|
530
|
+
const operation = capture();
|
|
531
|
+
const { ctx, stopped, io } = operation;
|
|
532
|
+
const root = await address(key, operation);
|
|
533
|
+
if (!root || stopped())
|
|
534
|
+
return 0;
|
|
535
|
+
const result = JSON.parse(entry.value);
|
|
536
|
+
const method = key.method;
|
|
537
|
+
if (catalogTtlMs(ctx, result.ttlMs) === 0) {
|
|
538
|
+
if (!stopped() && (await namespace(operation)) && !stopped())
|
|
539
|
+
await io(() => storage.delete(root));
|
|
540
|
+
return 0;
|
|
541
|
+
}
|
|
542
|
+
if (!Array.isArray(catalogItems(method, result)) ||
|
|
543
|
+
"nextCursor" in result ||
|
|
544
|
+
(result.resultType !== undefined && result.resultType !== "complete"))
|
|
545
|
+
return 0;
|
|
546
|
+
// Defence in depth: the SDK catches store failures, so intake also runs on
|
|
547
|
+
// the request path, where a secret-bearing name refuses the entire list.
|
|
548
|
+
const value = JSON.stringify(intake(ctx, method, result));
|
|
549
|
+
const fingerprint = await fingerprintSerializedCatalog(value);
|
|
550
|
+
const expiresAt = Math.min(entry.expiresAt ?? Date.now(), (catalogFetchedAt(ctx, method) ?? Date.now()) + catalogTtlMs(ctx, result.ttlMs));
|
|
551
|
+
if (stopped() || expiresAt <= Date.now() || !(await namespace(operation)) || stopped())
|
|
552
|
+
return 0;
|
|
553
|
+
const revision = crypto.randomUUID();
|
|
554
|
+
const bytes = new TextEncoder().encode(value);
|
|
555
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
556
|
+
let offset = 0;
|
|
557
|
+
let chunkCount = 0;
|
|
558
|
+
const ttlSeconds = () => Math.max(0.001, (expiresAt + (method === "tools/list" ? STALE_FALLBACK_MS : 0) - Date.now()) / 1000);
|
|
559
|
+
while (offset < bytes.length) {
|
|
560
|
+
if (stopped() || expiresAt <= Date.now())
|
|
561
|
+
return 0;
|
|
562
|
+
let end = Math.min(offset + MAX_CATALOG_CHUNK_BYTES, bytes.length);
|
|
563
|
+
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80)
|
|
564
|
+
end--;
|
|
565
|
+
const chunk = responseCacheKeys.chunk(root, revision, chunkCount++);
|
|
566
|
+
await io(() => storage.set(chunk, decoder.decode(bytes.subarray(offset, end)), { ttlSeconds: ttlSeconds() }));
|
|
567
|
+
if (stopped())
|
|
568
|
+
return 0;
|
|
569
|
+
offset = end;
|
|
570
|
+
}
|
|
571
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
572
|
+
if (stopped() || expiresAt <= Date.now() || !(await namespace(operation)) || stopped())
|
|
573
|
+
return 0;
|
|
574
|
+
const previous = await io(() => storage.get(root));
|
|
575
|
+
if (stopped() || expiresAt <= Date.now())
|
|
576
|
+
return 0;
|
|
577
|
+
const stamp = Math.max(Date.now(), (manifest(previous)?.stamp ?? 0) + 1, lastStamp + 1);
|
|
578
|
+
const m = {
|
|
579
|
+
stamp,
|
|
580
|
+
expiresAt,
|
|
581
|
+
fetchedAt: catalogFetchedAt(ctx, method) ?? Date.now(),
|
|
582
|
+
scope: sharing === "shared" && result.cacheScope === "public" ? "public" : "private",
|
|
583
|
+
revision,
|
|
584
|
+
chunkCount,
|
|
585
|
+
fingerprint: fingerprint.fingerprint,
|
|
586
|
+
};
|
|
587
|
+
const published = await io(() => storage.compareAndSet(root, previous, JSON.stringify(m), { ttlSeconds: ttlSeconds() }));
|
|
588
|
+
if (stopped())
|
|
589
|
+
return 0;
|
|
590
|
+
if (published) {
|
|
591
|
+
lastStamp = stamp;
|
|
592
|
+
return stamp;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
throw new Error("Catalog cache publication is busy.");
|
|
596
|
+
},
|
|
597
|
+
async delete(key) {
|
|
598
|
+
const operation = capture();
|
|
599
|
+
const root = await address(key, operation);
|
|
600
|
+
if (root && !operation.stopped())
|
|
601
|
+
await operation.io(() => storage.delete(root));
|
|
602
|
+
},
|
|
603
|
+
async evict(method) {
|
|
604
|
+
if (isCatalogMethod(method))
|
|
605
|
+
await store.clear();
|
|
606
|
+
},
|
|
607
|
+
async clear() {
|
|
608
|
+
const operation = capture();
|
|
609
|
+
if (!operation.stopped())
|
|
610
|
+
await operation.io(() => invalidateCatalogCache(storage, connectorId));
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
// Refresh/bypass listings may never call get(). Pin before any wire I/O so
|
|
614
|
+
// their first late publication cannot adopt a post-notification generation.
|
|
615
|
+
// Reading an absent generation pins a nonce without mutating storage before
|
|
616
|
+
// negotiation/auth succeeds. The first cache operation may claim that nonce.
|
|
617
|
+
try {
|
|
618
|
+
const { stopped, io } = capture({ ctx, generation: connectionGeneration, ended: false });
|
|
619
|
+
if (!stopped()) {
|
|
620
|
+
const current = await io(() => storage.get(responseCacheKeys.generation(connectorId)));
|
|
621
|
+
if (!stopped())
|
|
622
|
+
connectionGeneration.value = current ?? crypto.randomUUID();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
unavailable = true;
|
|
627
|
+
logFailure(ctx.logger, "catalog read failed", failureRecord({ connector: connectorId }, error));
|
|
628
|
+
}
|
|
629
|
+
let tail = Promise.resolve();
|
|
630
|
+
const withListing = (listingCtx, read) => {
|
|
631
|
+
const pending = tail.then(async () => {
|
|
632
|
+
if (scope.closed || connectionSignal?.aborted)
|
|
633
|
+
throw new ConnectorCallError("connector_call_failed", "Catalog cache scope ended.");
|
|
634
|
+
if (listingCtx.signal?.aborted)
|
|
635
|
+
throw listingCtx.signal.reason;
|
|
636
|
+
if (policy && !settings.has(listingCtx))
|
|
637
|
+
settings.set(listingCtx, policy);
|
|
638
|
+
const listing = { ctx: listingCtx, generation: {}, ended: false };
|
|
639
|
+
active = listing;
|
|
640
|
+
staleTools = undefined;
|
|
641
|
+
const operation = capture();
|
|
642
|
+
try {
|
|
643
|
+
if (resolveCredentialIdentity)
|
|
644
|
+
listing.credentialIdentity = await operation.io(resolveCredentialIdentity);
|
|
645
|
+
// Each new listing pins before even a refresh's first wire page. A
|
|
646
|
+
// notification fences this listing, while a later listing can refresh.
|
|
647
|
+
if (!operation.stopped()) {
|
|
648
|
+
try {
|
|
649
|
+
listing.generation.value =
|
|
650
|
+
(await operation.io(() => storage.get(responseCacheKeys.generation(connectorId)))) ?? crypto.randomUUID();
|
|
651
|
+
connectionGeneration.value = listing.generation.value;
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (operation.stopped())
|
|
655
|
+
throw error;
|
|
656
|
+
unavailable = true;
|
|
657
|
+
logFailure(ctx.logger, "catalog read failed", failureRecord({ connector: connectorId }, error));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return await read();
|
|
661
|
+
}
|
|
662
|
+
finally {
|
|
663
|
+
listing.ended = true;
|
|
664
|
+
active = undefined;
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
// Keep the binding through the entire SDK method, including its opposite
|
|
668
|
+
// partition delete even when set() failed. Only then may a sibling bind.
|
|
669
|
+
tail = pending.then(() => { }, () => { });
|
|
670
|
+
return pending;
|
|
671
|
+
};
|
|
672
|
+
const fallbackTools = async () => {
|
|
673
|
+
const operation = capture();
|
|
674
|
+
if (!staleTools ||
|
|
675
|
+
operation.stopped() ||
|
|
676
|
+
!(await namespace(operation)) ||
|
|
677
|
+
operation.stopped() ||
|
|
678
|
+
staleTools.manifest.expiresAt + STALE_FALLBACK_MS <= Date.now())
|
|
679
|
+
return;
|
|
680
|
+
const { result, manifest } = staleTools;
|
|
681
|
+
observeCatalogFetch(operation.ctx, manifest.fetchedAt);
|
|
682
|
+
observeCatalogExpiry(operation.ctx, "tools/list", typeof result.ttlMs === "number" ? result.ttlMs : 0, manifest.expiresAt, true);
|
|
683
|
+
return result;
|
|
684
|
+
};
|
|
685
|
+
return {
|
|
686
|
+
intake,
|
|
687
|
+
completeCatalogRefresh,
|
|
688
|
+
fallbackTools,
|
|
689
|
+
responseCacheStore: store,
|
|
690
|
+
cachePartition: partition,
|
|
691
|
+
defaultCacheTtlMs: policy?.defaultTtlMs ?? CONFIG_DEFAULTS.discovery.catalogTtlSeconds * 1000,
|
|
692
|
+
withListing,
|
|
693
|
+
currentContext: () => active?.ctx ?? ctx,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function isCatalogMethod(method) {
|
|
697
|
+
return method === "tools/list" || method === "resources/list" || method === "resources/templates/list";
|
|
698
|
+
}
|
|
699
|
+
async function customCatalogCache(ctx, connectorId) {
|
|
700
|
+
const credential = await ctx.credential?.getAll();
|
|
701
|
+
const auth = (await fingerprintSerializedCatalog(JSON.stringify(credential ?? null))).fingerprint;
|
|
702
|
+
const cache = await catalogClientOptions(ctx, connectorId, "custom-connector", auth);
|
|
703
|
+
const key = { method: "tools/list", partition: JSON.stringify(["custom-connector", cache.cachePartition]) };
|
|
704
|
+
return { cache, key };
|
|
705
|
+
}
|
|
706
|
+
/** Custom listings always fetch. Only an authenticated owner may retain a
|
|
707
|
+
* complete private fallback; unscoped/operator reads start no cache I/O. */
|
|
708
|
+
export async function storeCustomCatalogFallback(ctx, connectorId, tools) {
|
|
709
|
+
const ttlMs = catalogTtlMs(ctx, undefined);
|
|
710
|
+
observeCatalogFetch(ctx, Date.now());
|
|
711
|
+
observeCatalogExpiry(ctx, "tools/list", ttlMs);
|
|
712
|
+
if (!ttlMs || !callerOf(ctx)?.authenticated)
|
|
713
|
+
return;
|
|
714
|
+
const { cache, key } = await customCatalogCache(ctx, connectorId);
|
|
715
|
+
await cache.withListing(ctx, async () => {
|
|
716
|
+
const result = catalogIntake(ctx, {
|
|
717
|
+
tools: tools.map((tool) => ({
|
|
718
|
+
...tool,
|
|
719
|
+
inputSchema: tool.inputSchema ?? { type: "object" },
|
|
720
|
+
})),
|
|
721
|
+
ttlMs,
|
|
722
|
+
cacheScope: "private",
|
|
723
|
+
});
|
|
724
|
+
await cache.responseCacheStore.set(key, {
|
|
725
|
+
value: JSON.stringify(result),
|
|
726
|
+
expiresAt: catalogExpiry(ctx).expiresAt,
|
|
727
|
+
scope: "private",
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
export async function customCatalogFallback(ctx, connectorId) {
|
|
732
|
+
if (!callerOf(ctx)?.authenticated)
|
|
733
|
+
return;
|
|
734
|
+
const { cache, key } = await customCatalogCache(ctx, connectorId);
|
|
735
|
+
return cache.withListing(ctx, async () => {
|
|
736
|
+
await cache.responseCacheStore.get(key);
|
|
737
|
+
return (await cache.fallbackTools())?.tools;
|
|
738
|
+
});
|
|
739
|
+
}
|