@zackbart/connecta 0.39.0 → 0.40.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 +28 -0
- package/bin/connecta.mjs +4 -8
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.js +6 -4
- package/dist/auth/native.js +10 -0
- package/dist/catalog-cache.js +90 -17
- package/dist/connectors/remote-mcp/connection.js +13 -2
- package/dist/connectors/remote-mcp/oauth.js +4 -0
- package/dist/connectors/remote-mcp/transport.js +13 -2
- package/dist/connectors/remote-mcp.js +4 -1
- package/dist/invocation.js +5 -2
- package/dist/meta-tools.js +31 -23
- package/dist/native-tools.js +1 -1
- package/dist/provider-call-facts.js +6 -2
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/runtime/native-storage.js +3 -0
- package/dist/runtime/request-reads.js +25 -0
- package/dist/skills.js +1 -1
- package/dist/storage/sql-native.js +26 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +35 -1
- package/documentation/auth.md +16 -1
- package/documentation/integrating.md +2 -2
- package/documentation/meta-tools.md +21 -4
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.40.0 — 2026-11-02
|
|
6
|
+
|
|
7
|
+
This release makes warm Connecta calls on Workers cheaper in storage and closes a credential gap on OAuth remote MCP connectors. Deployments need no configuration change or migration, unless they use the Eve helper.
|
|
8
|
+
|
|
9
|
+
**Eve helper:** `@zackbart/connecta-eve` now requires exactly Eve 1.0.0-rc.1. Eve 0.90 apps must upgrade Eve together with the helper and, when installed, upgrade `@vercel/connect` to `^2.7.0`. The core package and non-Eve deployments are not affected by this change.
|
|
10
|
+
|
|
11
|
+
**Security:** an OAuth remote MCP connector no longer sends a passive handshake, catalog request or tool call without a credential if its tokens are removed mid-request. It returns OAuth recovery before sending instead. Before this, a server accepting anonymous requests could receive that request without the connector's credential.
|
|
12
|
+
|
|
13
|
+
**Native pools:** native pools now always list `authorize_connector`, so an agent on a pool without `execute_code` can get the operator a handoff for a connector that needs authorization. On those pools the repaired connector's tools appear only after the host refreshes its tool list, usually by reconnecting; pools with `execute_code` can reach them from a program straight away. Anything that compares a native pool's tool list exactly will see one more tool. Meta endpoints keep the same six tools.
|
|
14
|
+
|
|
15
|
+
**Storage work per call:** warm remote MCP catalog resolution now takes four D1 round trips instead of six on modern servers and five on legacy ones. Warm agent admission takes two reads instead of four. Warm OAuth calls read the grant 9 times instead of 11 on modern servers and 12 instead of 13 on legacy ones. A warm isolate reuses a stored catalog it has already validated, after the same freshness and partition checks. The catalog and admission round trips were measured with injected latency on local workerd, and the OAuth counts come from request-budget tests. None of this has been measured in production.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Read agent token admission state in one fresh D1 or SQLite statement, reducing warm admission and last-seen observation from four reads to two while preserving next-request rotation and revocation enforcement.
|
|
20
|
+
- **Breaking:** `@zackbart/connecta-eve` now requires exactly Eve 1.0.0-rc.1; Eve 0.90 apps must upgrade Eve together with the helper and, when installed, upgrade `@vercel/connect` to `^2.7.0`. Align installed-consumer probes and upgrade guidance with the tested release candidate. Later candidates and stable 1.0 require a compatibility check.
|
|
21
|
+
- Reduce grant reads on warm OAuth remote MCP calls while retaining fresh catalog acceptance, disconnect, token rotation and dispatch checks. Keep the separate invocation token read so same-epoch invalidation refuses writes with OAuth recovery before dispatch, and expiry changes are observed before early renewal.
|
|
22
|
+
- Reuse validated stored catalogs within a warm isolate after checking stored chunks, partition identity, expiry and the fresh generation fence. Retained facts are immutable and bounded; credential redaction still runs per request.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Reduce warm remote MCP catalog storage round trips by batching locating reads and private/public manifest probes while preserving fresh generation and OAuth credential acceptance checks.
|
|
27
|
+
- Keep authorization recovery available on native pools without code by listing authorize_connector with the granted connector ids, even when a connector's catalog cannot load.
|
|
28
|
+
|
|
29
|
+
### Security
|
|
30
|
+
|
|
31
|
+
- Require a credential on passive OAuth remote MCP sends, including handshakes, catalog requests and tool dispatches, regardless of provider replacement. Return OAuth recovery before sending when the SDK cannot obtain one, and count a verified unsent write as failed without reconciliation. Explicit authorization flows retain their challenges; writes that may already have left remain unknown.
|
|
32
|
+
|
|
5
33
|
## 0.39.0 — 2026-10-30
|
|
6
34
|
|
|
7
35
|
This release makes Connecta faster and leaner for agents and operators on Cloudflare Workers. A warm search over remote MCP connectors with cached catalogs no longer reconnects to them: an all-legacy warm search makes no downstream requests. Repeated storage reads are memoized per request, and catalog chunks are read in batches. OAuth tokens refresh before they expire instead of failing the first write after expiry. Legacy negotiation verdicts last six hours, with bounded recovery when a server changes protocol era. A connector that recently hung or failed slowly is reported in `catalogErrors` and skipped by unscoped search for about a minute, and unscoped discovery has a total budget. With one hung connector, three consecutive searches went from 30 s each to 5 s, 11 ms and 8 ms.
|
package/bin/connecta.mjs
CHANGED
|
@@ -258,18 +258,14 @@ async function doctor() {
|
|
|
258
258
|
`Native catalogs unavailable: ${surface.catalogErrors.join(", ")}. Inspect connector status and authentication.`,
|
|
259
259
|
);
|
|
260
260
|
const expected = native
|
|
261
|
-
? ["skills", executeCode ? "execute_code" : "read_result"].sort()
|
|
261
|
+
? ["authorize_connector", "skills", executeCode ? "execute_code" : "read_result"].sort()
|
|
262
262
|
: [...META_TOOL_NAMES].sort();
|
|
263
263
|
if (
|
|
264
264
|
native
|
|
265
265
|
? expected.some((name) => !actual.includes(name)) ||
|
|
266
|
-
[
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
"call_destructive_tool",
|
|
270
|
-
"authorize_connector",
|
|
271
|
-
executeCode ? "read_result" : "execute_code",
|
|
272
|
-
].some((name) => actual.includes(name))
|
|
266
|
+
["search_tools", "call_tool", "call_destructive_tool", executeCode ? "read_result" : "execute_code"].some(
|
|
267
|
+
(name) => actual.includes(name),
|
|
268
|
+
)
|
|
273
269
|
: JSON.stringify(actual) !== JSON.stringify(expected)
|
|
274
270
|
) {
|
|
275
271
|
throw new Error(
|
package/bin/version.mjs
CHANGED
package/dist/access-tokens.js
CHANGED
|
@@ -8,7 +8,7 @@ import { accessTokenKeys } from "./storage/keys.js";
|
|
|
8
8
|
import { AGENT_NAMESPACE, agentSubjectId, isAgentName, trustAgentProvider } from "./agent-identity.js";
|
|
9
9
|
import { keepErrorName } from "./redaction-identity.js";
|
|
10
10
|
import { agentConfig } from "./agent-config.js";
|
|
11
|
-
import { authCall, authCatch, authFlow, authInvoke, authMethod, authOwnMethod, authStorage, registerAuthMethod, } from "./auth/native.js";
|
|
11
|
+
import { authAccessToken, authCall, authCatch, authFlow, authInvoke, authMethod, authOwnMethod, authStorage, registerAuthMethod, } from "./auth/native.js";
|
|
12
12
|
import { runEdge } from "./runtime/run.js";
|
|
13
13
|
const TOKEN_PREFIX = "cta_";
|
|
14
14
|
const TOKEN_BYTES = 32;
|
|
@@ -802,7 +802,9 @@ export class AccessTokenManager {
|
|
|
802
802
|
signal.throwIfAborted();
|
|
803
803
|
const hash = (yield authCall(hashToken, [token]));
|
|
804
804
|
signal.throwIfAborted();
|
|
805
|
-
const
|
|
805
|
+
const snapshot = (yield authAccessToken(this.storage, hash));
|
|
806
|
+
const read = (key) => snapshot ? (snapshot.get(key) ?? null) : authStorage(this.storage, "get", [key]);
|
|
807
|
+
const lookupRaw = (yield authCall(read, [lookupKey(hash)]));
|
|
806
808
|
signal.throwIfAborted();
|
|
807
809
|
if (!lookupRaw)
|
|
808
810
|
return unauthorized();
|
|
@@ -810,7 +812,7 @@ export class AccessTokenManager {
|
|
|
810
812
|
if (!lookup)
|
|
811
813
|
return unauthorized();
|
|
812
814
|
const recordRaw = ID_RE.test(lookup.id)
|
|
813
|
-
? (yield
|
|
815
|
+
? (yield authCall(read, [recordKey(lookup.id)]))
|
|
814
816
|
: null;
|
|
815
817
|
const record = recordRaw ? parseRecord(recordRaw) : null;
|
|
816
818
|
if (record && record.id !== lookup.id)
|
|
@@ -827,7 +829,7 @@ export class AccessTokenManager {
|
|
|
827
829
|
// code, refuse exactly as an unknown token does.
|
|
828
830
|
if (record.principal || record.agent === undefined || !this.agents.has(record.agent))
|
|
829
831
|
return unauthorized();
|
|
830
|
-
const slotRaw = (yield
|
|
832
|
+
const slotRaw = (yield authCall(read, [slotKey(record.agent)]));
|
|
831
833
|
const slot = slotRaw === null ? null : parseSlot(slotRaw, record.agent);
|
|
832
834
|
signal.throwIfAborted();
|
|
833
835
|
if (slot?.state !== "issued" || slot.tokenId !== record.id)
|
package/dist/auth/native.js
CHANGED
|
@@ -117,6 +117,16 @@ export function authStorage(storage, key, args, beforeSubmit) {
|
|
|
117
117
|
return Effect.flatMap(Effect.serviceOption(AuthenticationWork), (work) => work._tag === "Some" ? authDetached(submitted) : submitted);
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
|
+
/** Optional fresh token snapshot, with the same request ownership as an auth storage read. */
|
|
121
|
+
export function authAccessToken(storage, hash) {
|
|
122
|
+
return Effect.suspend(() => {
|
|
123
|
+
const read = getNativeStorage(storage)?.accessToken;
|
|
124
|
+
if (!read)
|
|
125
|
+
return Effect.succeed(undefined);
|
|
126
|
+
const operation = authValue(() => read(hash));
|
|
127
|
+
return Effect.flatMap(Effect.serviceOption(AuthenticationWork), (work) => work._tag === "Some" ? authDetached(operation) : operation);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
120
130
|
/** Several keys in one round trip when the store can, else one `get` at a time. */
|
|
121
131
|
export function authStorageMany(storage, keys) {
|
|
122
132
|
return Effect.suspend(() => {
|
package/dist/catalog-cache.js
CHANGED
|
@@ -412,6 +412,40 @@ const invalidateCatalogCacheNative = /* @__PURE__ */ registerAuth(invalidateCata
|
|
|
412
412
|
advanceCatalogRevision(storage);
|
|
413
413
|
}
|
|
414
414
|
}));
|
|
415
|
+
// Only detached JSON facts cross requests: no context, storage, promises or
|
|
416
|
+
// callbacks. FIFO bounds retained partitions, JSON/key text (UTF-16 worst
|
|
417
|
+
// case) and structural slots. No WeakRef or timers required.
|
|
418
|
+
const catalogStores = new WeakMap();
|
|
419
|
+
const validatedCatalogs = new Map();
|
|
420
|
+
let validatedBytes = 0;
|
|
421
|
+
const VALIDATED_BYTES = 8 * 1024 * 1024;
|
|
422
|
+
function rememberValidated(key, value, bytes) {
|
|
423
|
+
bytes = bytes * 2 + key.length * 2;
|
|
424
|
+
if (bytes > VALIDATED_BYTES || validatedCatalogs.has(key))
|
|
425
|
+
return;
|
|
426
|
+
const pending = [value];
|
|
427
|
+
while (pending.length) {
|
|
428
|
+
const item = pending.pop();
|
|
429
|
+
if (!item || typeof item !== "object" || Object.isFrozen(item))
|
|
430
|
+
continue;
|
|
431
|
+
Object.freeze(item);
|
|
432
|
+
for (const child of Object.values(item)) {
|
|
433
|
+
// Charge structural overhead too: tiny JSON objects/arrays can occupy
|
|
434
|
+
// much more heap than their serialized bytes alone suggest.
|
|
435
|
+
bytes += 64;
|
|
436
|
+
if (bytes > VALIDATED_BYTES)
|
|
437
|
+
return;
|
|
438
|
+
pending.push(child);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
while (validatedCatalogs.size >= 4 || validatedBytes + bytes > VALIDATED_BYTES) {
|
|
442
|
+
const oldest = validatedCatalogs.keys().next().value;
|
|
443
|
+
validatedBytes -= validatedCatalogs.get(oldest).bytes;
|
|
444
|
+
validatedCatalogs.delete(oldest);
|
|
445
|
+
}
|
|
446
|
+
validatedCatalogs.set(key, { value, bytes });
|
|
447
|
+
validatedBytes += bytes;
|
|
448
|
+
}
|
|
415
449
|
/** Keys ignore self-reported serverInfo and bind the configured connector instead. */
|
|
416
450
|
export function catalogClientOptions(ctx, connectorId, config, authPartition, connectionSignal, owner, sharing = "private", resolveCredentialIdentity, connectionless = false) {
|
|
417
451
|
return runEdge(authProgram(catalogClientOptionsNative, [
|
|
@@ -453,6 +487,9 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
453
487
|
authPartition,
|
|
454
488
|
]);
|
|
455
489
|
const storage = policy?.storage ?? ctx.storage;
|
|
490
|
+
let storeIdentity = catalogStores.get(storage);
|
|
491
|
+
if (!storeIdentity)
|
|
492
|
+
catalogStores.set(storage, (storeIdentity = crypto.randomUUID()));
|
|
456
493
|
const configHash = (yield authCall(fingerprintSerializedCatalog, [
|
|
457
494
|
JSON.stringify([
|
|
458
495
|
config,
|
|
@@ -518,7 +555,8 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
518
555
|
}));
|
|
519
556
|
const namespace = cacheMethod((operation, memoized = false) => authFlow(function* () {
|
|
520
557
|
const { stopped, generation, io } = operation;
|
|
521
|
-
if (
|
|
558
|
+
if (!memoized &&
|
|
559
|
+
resolveCredentialIdentity &&
|
|
522
560
|
(yield authCall(io, [resolveCredentialIdentity])) !== operation.credentialIdentity)
|
|
523
561
|
return undefined;
|
|
524
562
|
const current = (yield authCall(currentGeneration, [operation, memoized]));
|
|
@@ -544,7 +582,9 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
544
582
|
pair.length !== 2 ||
|
|
545
583
|
(pair[1] !== partition && (pair[1] !== "" || sharing !== "shared")))
|
|
546
584
|
return undefined;
|
|
547
|
-
//
|
|
585
|
+
// Locate with the listing's pinned identity and generation. Neither
|
|
586
|
+
// authorizes acceptance: get's post-chunk fence and set's publication
|
|
587
|
+
// re-read both, so rotation or disconnect during I/O refuses the entry.
|
|
548
588
|
const prefix = (yield authCall(namespace, [operation, true]));
|
|
549
589
|
if (!prefix || stopped())
|
|
550
590
|
return undefined;
|
|
@@ -639,9 +679,28 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
639
679
|
const root = (yield authCall(address, [key, operation]));
|
|
640
680
|
if (!root || stopped())
|
|
641
681
|
return undefined;
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
682
|
+
// The SDK probes private, then public. Derive both addresses from
|
|
683
|
+
// the already-bound locating generation and identity, and fetch
|
|
684
|
+
// them in one statement. The post-chunk namespace check stays fresh.
|
|
685
|
+
const listing = operation.listing;
|
|
686
|
+
if (listing && !listing.manifests?.has(root)) {
|
|
687
|
+
const slots = sharing === "shared" ? [partition, sharedPartition] : [partition];
|
|
688
|
+
const roots = [];
|
|
689
|
+
for (const slot of slots) {
|
|
690
|
+
const { fingerprint } = (yield authCall(fingerprintSerializedCatalog, [
|
|
691
|
+
JSON.stringify([key.method, slot, operation.credentialIdentity ?? null]),
|
|
692
|
+
]));
|
|
693
|
+
roots.push(responseCacheKeys.entry(responseCacheKeys.namespace(connectorId, configHash, operation.generation.value), fingerprint));
|
|
694
|
+
}
|
|
695
|
+
const values = (yield authCall(io, [cacheMethod(() => authStorageMany(storage, roots))]));
|
|
696
|
+
listing.manifests ??= new Map();
|
|
697
|
+
roots.forEach((root, i) => listing.manifests.set(root, values[i] ?? null));
|
|
698
|
+
}
|
|
699
|
+
const m = manifest(listing?.manifests?.has(root)
|
|
700
|
+
? listing.manifests.get(root)
|
|
701
|
+
: (yield authCall(io, [
|
|
702
|
+
cacheMethod(() => authStorage(storage, "get", [root])),
|
|
703
|
+
])));
|
|
645
704
|
if (stopped() ||
|
|
646
705
|
!m ||
|
|
647
706
|
(m.expiresAt <= Date.now() &&
|
|
@@ -670,18 +729,29 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
670
729
|
!(yield authCall(namespace, [operation])) ||
|
|
671
730
|
stopped())
|
|
672
731
|
return undefined;
|
|
673
|
-
const result = JSON.parse(value);
|
|
674
732
|
const method = key.method;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
733
|
+
// root binds connector/config/generation/scope/credential; partition
|
|
734
|
+
// also binds the current owner/deployment even for public entries.
|
|
735
|
+
// Hash the chunks again above, never trust the manifest alone.
|
|
736
|
+
const reuseKey = JSON.stringify([storeIdentity, root, partition, m.fingerprint]);
|
|
737
|
+
let accepted = validatedCatalogs.get(reuseKey)?.value;
|
|
738
|
+
if (!accepted) {
|
|
739
|
+
const result = JSON.parse(value);
|
|
740
|
+
const items = catalogItems(method, result);
|
|
741
|
+
if (!Array.isArray(items) ||
|
|
742
|
+
items.length > MAX_CATALOG_TOOLS ||
|
|
743
|
+
"nextCursor" in result ||
|
|
744
|
+
(result.resultType !== undefined && result.resultType !== "complete"))
|
|
745
|
+
return undefined;
|
|
746
|
+
const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
|
|
747
|
+
if (stopped() || validated.issues)
|
|
748
|
+
return undefined;
|
|
749
|
+
accepted = validated.value;
|
|
750
|
+
}
|
|
751
|
+
// Redaction depends on this request's sent credentials. It must
|
|
752
|
+
// still run, and returns detached objects from the frozen facts.
|
|
753
|
+
const clean = intake(ctx, method, accepted);
|
|
754
|
+
rememberValidated(reuseKey, accepted, fingerprint.byteLength);
|
|
685
755
|
if (m.expiresAt <= Date.now()) {
|
|
686
756
|
if (method === "tools/list" && (!staleTools || staleTools.manifest.fetchedAt < m.fetchedAt))
|
|
687
757
|
staleTools = { result: clean, manifest: m };
|
|
@@ -697,6 +767,7 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
697
767
|
const root = (yield authCall(address, [key, operation]));
|
|
698
768
|
if (!root || stopped())
|
|
699
769
|
return 0;
|
|
770
|
+
operation.listing?.manifests?.delete(root);
|
|
700
771
|
const result = JSON.parse(entry.value);
|
|
701
772
|
const method = key.method;
|
|
702
773
|
if (catalogTtlMs(ctx, result.ttlMs) === 0) {
|
|
@@ -783,10 +854,12 @@ const catalogClientOptionsNative = /* @__PURE__ */ registerAuth(catalogClientOpt
|
|
|
783
854
|
delete: cacheMethod((key) => authFlow(function* () {
|
|
784
855
|
const operation = capture();
|
|
785
856
|
const root = (yield authCall(address, [key, operation]));
|
|
786
|
-
if (root && !operation.stopped())
|
|
857
|
+
if (root && !operation.stopped()) {
|
|
858
|
+
operation.listing?.manifests?.delete(root);
|
|
787
859
|
(yield authInvoke(operation, "io", [
|
|
788
860
|
cacheMethod(() => authStorage(storage, "delete", [root])),
|
|
789
861
|
]));
|
|
862
|
+
}
|
|
790
863
|
})),
|
|
791
864
|
evict: cacheMethod((method) => authFlow(function* () {
|
|
792
865
|
if (isCatalogMethod(method))
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// served without connecting (INV-10).
|
|
4
4
|
import { routePhrases } from "../../agent-routes.js";
|
|
5
5
|
import { authView } from "../../auth/native-call.js";
|
|
6
|
-
import { authFlow, authCall, authInvoke, authMethod } from "../../auth/native.js";
|
|
7
|
-
import { openRequest, viewServices, viewSignal } from "../../runtime/call.js";
|
|
6
|
+
import { authFlow, authCall, authInvoke, authMethod, authStorageMany } from "../../auth/native.js";
|
|
7
|
+
import { openRequest, viewServices, viewSignal, viewReads, serviceFor } from "../../runtime/call.js";
|
|
8
8
|
import { secretDigest } from "../../storage-digest.js";
|
|
9
9
|
import { downstreamClientMetadata, downstreamRedirectUri, remoteClientAuthMethod, selfHostedClientUrl, } from "../../auth/downstream-client-metadata.js";
|
|
10
10
|
import { Client, SdkErrorCode, SdkHttpError, UnauthorizedError } from "@modelcontextprotocol/client";
|
|
@@ -23,6 +23,8 @@ import { downstreamInputCapabilities } from "../../downstream-input-context.js";
|
|
|
23
23
|
import { detach, runEdge } from "../../runtime/run.js";
|
|
24
24
|
import { requestPrincipalFor } from "../../request-principal.js";
|
|
25
25
|
import { callerOf } from "../../connector-caller.js";
|
|
26
|
+
import { CatalogPolicy } from "../../catalog-policy.js";
|
|
27
|
+
import { negotiationKeys, responseCacheKeys } from "../../storage/keys.js";
|
|
26
28
|
import { forgetNegotiation, readNegotiation, storeNegotiation } from "../negotiation-cache.js";
|
|
27
29
|
import { carriesIllegalHeaderChar, credentialHeaderValue } from "./config.js";
|
|
28
30
|
import { AUTH_OR_THROTTLE, promised, recordWireErrors, requiresAuthorization } from "./errors.js";
|
|
@@ -293,6 +295,15 @@ export function remoteMcpConnection(rt) {
|
|
|
293
295
|
}
|
|
294
296
|
if (opts.versionNegotiation !== "legacy") {
|
|
295
297
|
const digest = (yield authCall(negotiationKey, [ctx, credentialDigest, epoch]));
|
|
298
|
+
// Both reads only locate cached data. Acceptance and dispatch keep
|
|
299
|
+
// their fresh generation and credential checks.
|
|
300
|
+
yield viewReads(ctx).prefetch([
|
|
301
|
+
{ storage: ctx.storage, key: negotiationKeys.verdict(digest) },
|
|
302
|
+
{
|
|
303
|
+
storage: serviceFor(ctx, CatalogPolicy)?.storage ?? ctx.storage,
|
|
304
|
+
key: responseCacheKeys.generation(id),
|
|
305
|
+
},
|
|
306
|
+
], (storage, keys) => authStorageMany(storage, keys));
|
|
296
307
|
const prior = (yield authCall(readNegotiation, [ctx, digest]));
|
|
297
308
|
preread.negotiation = { digest, prior };
|
|
298
309
|
if (prior?.kind === "modern")
|
|
@@ -399,6 +399,10 @@ export function remoteMcpOAuth(rt) {
|
|
|
399
399
|
connector.startAuth = authMethod((ctx, startOpts) => authFlow(function* () {
|
|
400
400
|
ctx = authorizingContext(ctx);
|
|
401
401
|
const state = rt.stateFor(ctx);
|
|
402
|
+
// A passive tokenless handshake was refused locally. An explicit
|
|
403
|
+
// start gets its own challenge-capable connection attempt.
|
|
404
|
+
if (state.authRequired && !state.client)
|
|
405
|
+
rt.closeHalf(state);
|
|
402
406
|
state.provider = null;
|
|
403
407
|
const p = rt.newProvider(ctx, state);
|
|
404
408
|
if (ctx.signal?.aborted)
|
|
@@ -19,7 +19,7 @@ import { RequestScope } from "../../runtime/request-scope.js";
|
|
|
19
19
|
import { Context } from "effect";
|
|
20
20
|
import { downstreamWriteContinuation } from "../../downstream-input-context.js";
|
|
21
21
|
import { guardedTransport, sendGuarded, sendCredentialPost } from "../../guarded-transport.js";
|
|
22
|
-
import { dispatchProviderRequest, recordProviderResponse, providerRequest, renewedAfterUnauthorized, } from "../../provider-call.js";
|
|
22
|
+
import { dispatchProviderRequest, recordProviderResponse, knownProviderFailure, providerRequest, renewedAfterUnauthorized, } from "../../provider-call.js";
|
|
23
23
|
import { recordRequestField } from "../../provider-call-facts.js";
|
|
24
24
|
import { AUTH_OR_THROTTLE, errorBodyText, jsonRpcErrorBody, keepsModernVerdict, promised } from "./errors.js";
|
|
25
25
|
import { MAX_SKILL_READ_RPC_BYTES, boundedSkillResponse, skillObject } from "./resources.js";
|
|
@@ -273,6 +273,17 @@ export function remoteMcpTransport(rt) {
|
|
|
273
273
|
if (toolCall && !flight && !untrackedRead(toolCall.params))
|
|
274
274
|
throw missingDispatch();
|
|
275
275
|
const credentialPost = isOauth && (init?.method ?? "GET").toUpperCase() === "POST" && (!skillObject(rpc) || rpc.jsonrpc !== "2.0");
|
|
276
|
+
// OAuth discovery, registration and exchange share this fetch, but do
|
|
277
|
+
// not use the resource credential. Guard the assembled MCP request,
|
|
278
|
+
// after the SDK token read and before dispatch bookkeeping or fetch.
|
|
279
|
+
// OAuth is required on passive paths regardless of which provider
|
|
280
|
+
// read the grant. Only an explicit authorization start may probe the
|
|
281
|
+
// resource anonymously for an SDK challenge, never a tools/call.
|
|
282
|
+
if (isOauth && !credentialPost && new URL(input).href === url.href) {
|
|
283
|
+
const authorization = new Headers(init?.headers).get("authorization");
|
|
284
|
+
if ((provider?.allowAuthorization !== true || toolCall) && !authorization)
|
|
285
|
+
throw knownProviderFailure(rt.authRequiredError(ctx), flight?.sent ? undefined : (flight?.request?.entry ?? providerRequest(ctx, true, id)));
|
|
286
|
+
}
|
|
276
287
|
// A custom transport reaches only the configured origin; redirects are
|
|
277
288
|
// already same-origin, and OAuth cannot be combined with it.
|
|
278
289
|
if (customFetch && new URL(input instanceof Request ? input.url : String(input)).origin !== destination.origin)
|
|
@@ -410,7 +421,7 @@ export function remoteMcpTransport(rt) {
|
|
|
410
421
|
}
|
|
411
422
|
})));
|
|
412
423
|
if (opts.auth?.type === "oauth") {
|
|
413
|
-
const oauthProvider = provider
|
|
424
|
+
const oauthProvider = (provider ??= rt.newProvider(ctx, undefined, signal));
|
|
414
425
|
// The session/provider belong to the request. A renewal belongs to the
|
|
415
426
|
// exact call whose HTTP response challenged it, until claim submission.
|
|
416
427
|
// Carry that ownership on the response the SDK passes to onUnauthorized;
|
|
@@ -481,9 +481,12 @@ export function remoteMcp(id, opts) {
|
|
|
481
481
|
const provider = newProvider(ctx, stateFor(ctx));
|
|
482
482
|
if ((yield authInvoke(provider, "operatorDisconnected", [])))
|
|
483
483
|
throw operatorDisconnectedError();
|
|
484
|
+
// Keep this separate from the disconnect read: invalidation and
|
|
485
|
+
// refresh can remove or replace tokens without changing the epoch.
|
|
486
|
+
// The SDK may otherwise send anonymously when its tokens() is empty.
|
|
484
487
|
if (!(yield authInvoke(provider, "tokens", [])))
|
|
485
488
|
throw authRequiredError(ctx);
|
|
486
|
-
// That read decides whether the token is about to expire.
|
|
489
|
+
// That read also decides whether the token is about to expire.
|
|
487
490
|
const due = provider.renewalDue();
|
|
488
491
|
if (due !== undefined)
|
|
489
492
|
(yield authCall(renewBeforeCall, [ctx, due]));
|
package/dist/invocation.js
CHANGED
|
@@ -16,7 +16,7 @@ import { recoveryRecord, recordRecoveryFacts } from "./call-recovery.js";
|
|
|
16
16
|
import { callFromContext, contextView, viewServices, Credentials } from "./runtime/call.js";
|
|
17
17
|
import { chargeCallBudget } from "./runtime/call-admission.js";
|
|
18
18
|
import { callEntry } from "./runtime/call-entry.js";
|
|
19
|
-
import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProtectsCall, knownCallFailure, onlyAuxiliaryRequests, knownProviderFailure, openLogicalCall, } from "./provider-call.js";
|
|
19
|
+
import { forgetProviderFailure, isKnownProviderFailure, isSettledWrite, keyProtectsCall, knownCallFailure, refusalRequest, onlyAuxiliaryRequests, knownProviderFailure, openLogicalCall, } from "./provider-call.js";
|
|
20
20
|
import { classifyWriteOutcome, settleWrite, settleWriteResult } from "./write-outcome.js";
|
|
21
21
|
import { classificationDigest, recordAuthFailure, recordCallEntry, replayClassificationDigest, resolveInvocationAuthEffect, } from "./invocation-auth.js";
|
|
22
22
|
import { downstreamContinuation, recordDownstreamArgumentEcho, isDownstreamInputResult, assertDownstreamOutputSafe, } from "./downstream-input-context.js";
|
|
@@ -288,10 +288,13 @@ export class InvocationService {
|
|
|
288
288
|
}
|
|
289
289
|
if (mayHaveWritten &&
|
|
290
290
|
(error.code === "auth_required" || error.code === "downstream_oauth_required") &&
|
|
291
|
-
!logicalCall?.sent.some((request) => request.dispatched)
|
|
291
|
+
!logicalCall?.sent.some((request) => request.dispatched) &&
|
|
292
|
+
!(answered && refusalRequest(error)?.dispatched === false)) {
|
|
292
293
|
// An empty resource ledger cannot settle this auth failure. Keep
|
|
293
294
|
// actual resource-refusal provenance, but withdraw an empty ledger's
|
|
294
295
|
// known mark before composing advice and counting program writes.
|
|
296
|
+
// A boundary-owned physical pre-dispatch refusal is positive proof;
|
|
297
|
+
// its private provenance cannot be supplied by error fields.
|
|
295
298
|
answered = false;
|
|
296
299
|
forgetProviderFailure(error);
|
|
297
300
|
}
|
package/dist/meta-tools.js
CHANGED
|
@@ -1130,6 +1130,37 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
1130
1130
|
bindRequest(request);
|
|
1131
1131
|
return yield* upwardStep(authInvoke(mt, "skills", [args]));
|
|
1132
1132
|
})));
|
|
1133
|
+
server.registerTool("authorize_connector", {
|
|
1134
|
+
description: ctx.native
|
|
1135
|
+
? AUTHORIZE_DESC +
|
|
1136
|
+
" Use when a connector’s tools are missing. Granted: " +
|
|
1137
|
+
registry
|
|
1138
|
+
.listConnectors()
|
|
1139
|
+
.map((connector) => connector.id)
|
|
1140
|
+
.join(", ") +
|
|
1141
|
+
"."
|
|
1142
|
+
: AUTHORIZE_DESC,
|
|
1143
|
+
inputSchema: AUTHORIZE_INPUT,
|
|
1144
|
+
outputSchema: AUTHORIZE_OUTPUT,
|
|
1145
|
+
// Starts (or with force, resets) a downstream OAuth flow — it changes
|
|
1146
|
+
// stored connector auth state, so it is deliberately not read-only.
|
|
1147
|
+
annotations: {
|
|
1148
|
+
readOnlyHint: false,
|
|
1149
|
+
destructiveHint: false,
|
|
1150
|
+
openWorldHint: true,
|
|
1151
|
+
},
|
|
1152
|
+
}, authMethod((args, request) => authFlow(function* () {
|
|
1153
|
+
bindRequest(request);
|
|
1154
|
+
const run = authMethod(() => authInvoke(mt, "authorizeConnector", [args]));
|
|
1155
|
+
return yield* upwardStep(ctx.authElicitation
|
|
1156
|
+
? authInvoke(ctx.authElicitation, "run", [
|
|
1157
|
+
"authorize_connector",
|
|
1158
|
+
args,
|
|
1159
|
+
request,
|
|
1160
|
+
run,
|
|
1161
|
+
])
|
|
1162
|
+
: authCall(run, []));
|
|
1163
|
+
})));
|
|
1133
1164
|
if (ctx.native) {
|
|
1134
1165
|
if (!ctx.native.executeCode) {
|
|
1135
1166
|
server.registerTool("read_result", { description: READ_RESULT_DESC, inputSchema: READ_RESULT_INPUT, annotations: READ_ONLY_LOCAL }, authMethod((args, request) => authFlow(function* () {
|
|
@@ -1201,28 +1232,5 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
1201
1232
|
])
|
|
1202
1233
|
: authInvoke(mt, "callDestructiveTool", [call]));
|
|
1203
1234
|
})));
|
|
1204
|
-
server.registerTool("authorize_connector", {
|
|
1205
|
-
description: AUTHORIZE_DESC,
|
|
1206
|
-
inputSchema: AUTHORIZE_INPUT,
|
|
1207
|
-
outputSchema: AUTHORIZE_OUTPUT,
|
|
1208
|
-
// Starts (or with force, resets) a downstream OAuth flow — it changes
|
|
1209
|
-
// stored connector auth state, so it is deliberately not read-only.
|
|
1210
|
-
annotations: {
|
|
1211
|
-
readOnlyHint: false,
|
|
1212
|
-
destructiveHint: false,
|
|
1213
|
-
openWorldHint: true,
|
|
1214
|
-
},
|
|
1215
|
-
}, authMethod((args, request) => authFlow(function* () {
|
|
1216
|
-
bindRequest(request);
|
|
1217
|
-
const run = authMethod(() => authInvoke(mt, "authorizeConnector", [args]));
|
|
1218
|
-
return yield* upwardStep(ctx.authElicitation
|
|
1219
|
-
? authInvoke(ctx.authElicitation, "run", [
|
|
1220
|
-
"authorize_connector",
|
|
1221
|
-
args,
|
|
1222
|
-
request,
|
|
1223
|
-
run,
|
|
1224
|
-
])
|
|
1225
|
-
: authCall(run, []));
|
|
1226
|
-
})));
|
|
1227
1235
|
return undefined;
|
|
1228
1236
|
}
|
package/dist/native-tools.js
CHANGED
|
@@ -29,7 +29,7 @@ const SEPARATOR = "__";
|
|
|
29
29
|
const PLAIN_TOOL = /^[A-Za-z0-9-](?:[A-Za-z0-9-]|_(?!_))*$/;
|
|
30
30
|
const NATIVE_NAME = new RegExp(`^[A-Za-z][A-Za-z0-9_-]{0,${NATIVE_NAME_MAX - 1}}$`);
|
|
31
31
|
/** Connecta's own tools on a native endpoint. Their names never contain `__`. */
|
|
32
|
-
const NATIVE_OWN_TOOLS = ["execute_code", "skills", "read_result"];
|
|
32
|
+
const NATIVE_OWN_TOOLS = ["execute_code", "skills", "read_result", "authorize_connector"];
|
|
33
33
|
const enc = new TextEncoder();
|
|
34
34
|
function shortHash(text) {
|
|
35
35
|
return runEdge(shortHashNative(text));
|
|
@@ -133,9 +133,13 @@ export function renewedAfterUnauthorized(request, sent, fresh) {
|
|
|
133
133
|
export function refusalRequest(error) {
|
|
134
134
|
return error !== null && typeof error === "object" ? refusals.get(error) : undefined;
|
|
135
135
|
}
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
/** A local refusal may additionally name the physical request it prevented. */
|
|
137
|
+
export function knownProviderFailure(error, unsent) {
|
|
138
|
+
if (error !== null && typeof error === "object") {
|
|
138
139
|
knownFailures.add(error);
|
|
140
|
+
if (unsent && !unsent.dispatched)
|
|
141
|
+
refusals.set(error, unsent);
|
|
142
|
+
}
|
|
139
143
|
return error;
|
|
140
144
|
}
|
|
141
145
|
export function isKnownProviderFailure(error) {
|
|
@@ -55,4 +55,4 @@ export declare const PLANNING_CENTER_MAX_RESPONSE_BYTES: number;
|
|
|
55
55
|
*/
|
|
56
56
|
export declare const PLANNING_CENTER_ADMISSION: ConnectorCallAdmissionPolicy;
|
|
57
57
|
/** Planning Center refuses a request without a descriptive one (HTTP 403). */
|
|
58
|
-
export declare const DEFAULT_USER_AGENT = "connecta/0.
|
|
58
|
+
export declare const DEFAULT_USER_AGENT = "connecta/0.40.0 (+https://github.com/zackbart/connecta)";
|
|
@@ -25,6 +25,9 @@ export function registerNativeStorage(edge, native) {
|
|
|
25
25
|
storageMethods.set(edge, own);
|
|
26
26
|
const getMany = native.getMany;
|
|
27
27
|
storage.set(edge, {
|
|
28
|
+
accessToken: native.accessToken
|
|
29
|
+
? (hash) => typed(() => (getNativeStorage(edge) ? native.accessToken(hash) : Effect.succeed(undefined)))
|
|
30
|
+
: undefined,
|
|
28
31
|
get: (key) => current(edge, "get", own.get, [key], () => native.get(key)),
|
|
29
32
|
// A replaced `get` answers each key itself, in order, as before.
|
|
30
33
|
...(getMany
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
// `FinalizationRegistry` (workerd before compatibility date 2025-05-05 without
|
|
37
37
|
// `enable_weak_ref`) memoizes nothing: every read goes to storage.
|
|
38
38
|
import { Effect } from "effect";
|
|
39
|
+
const roots = new WeakMap();
|
|
39
40
|
const observed = new WeakMap();
|
|
40
41
|
/**
|
|
41
42
|
* Open memos holding or reading entries of each store. Weak, so a request
|
|
@@ -72,6 +73,8 @@ export function observedWrite(store, key, write) {
|
|
|
72
73
|
* one is.
|
|
73
74
|
*/
|
|
74
75
|
export function observeStorageWrites(storage, layer) {
|
|
76
|
+
if ("root" in layer)
|
|
77
|
+
roots.set(layer.root, storage);
|
|
75
78
|
observed.set(storage, {
|
|
76
79
|
own: {
|
|
77
80
|
get: storage.get,
|
|
@@ -159,6 +162,28 @@ export class RequestReads {
|
|
|
159
162
|
return Effect.tap(load, (value) => Effect.sync(() => found.record(value)));
|
|
160
163
|
});
|
|
161
164
|
}
|
|
165
|
+
/** Prefetch only keys whose callers already permit request-local staleness. */
|
|
166
|
+
prefetch(requests, load) {
|
|
167
|
+
return Effect.suspend(() => {
|
|
168
|
+
const pending = requests.map(({ storage, key }) => ({
|
|
169
|
+
address: physical(storage, key),
|
|
170
|
+
found: this.lookup(storage, key, false),
|
|
171
|
+
}));
|
|
172
|
+
const root = pending[0]?.address?.store;
|
|
173
|
+
const storage = root && roots.get(root);
|
|
174
|
+
if (!storage || pending.some(({ address, found }) => !found || address?.store !== root))
|
|
175
|
+
return Effect.void;
|
|
176
|
+
const misses = pending.filter(({ found }) => found && !found.hit);
|
|
177
|
+
if (!misses.length)
|
|
178
|
+
return Effect.void;
|
|
179
|
+
return Effect.map(load(storage, misses.map(({ address }) => address.key)), (values) => {
|
|
180
|
+
misses.forEach(({ found }, i) => {
|
|
181
|
+
if (found && !found.hit)
|
|
182
|
+
found.record(values[i] ?? null);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
162
187
|
/** `read` for a Promise-shaped caller, without another Effect edge. */
|
|
163
188
|
async readPromise(storage, key, load, fresh = false) {
|
|
164
189
|
const found = this.lookup(storage, key, fresh);
|
package/dist/skills.js
CHANGED
|
@@ -35,7 +35,7 @@ export function endpointInstructions(description, native) {
|
|
|
35
35
|
/** Instructions for pools whose host searches the listed native tools. */
|
|
36
36
|
export function nativeInstructions(executeCode) {
|
|
37
37
|
const routes = routeNames(executeCode ? "native-code" : "native");
|
|
38
|
-
return `Each tool here is one operation; annotations classify reads/writes. Call known tools directly; ${executeCode ? `combine work in ${routes.program} using the program addresses in tool descriptions.` : "find tools with your host's search."} Read-only endpoints omit and refuse writes. Never repeat a write for output. Give auth handoffs to the user/operator. Follow nextAction to page with ${executeCode ? `connecta.result in ${routes.program}` : "read_result"}. ${routes.skills} lists guidance.`;
|
|
38
|
+
return `Each tool here is one operation; annotations classify reads/writes. Call known tools directly; ${executeCode ? `combine work in ${routes.program} using the program addresses in tool descriptions.` : "find tools with your host's search."} Read-only endpoints omit and refuse writes. Never repeat a write for output. Give auth handoffs to the user/operator. Tools missing? Call authorize_connector; after repair, reconnect to refresh your list. Follow nextAction to page with ${executeCode ? `connecta.result in ${routes.program}` : "read_result"}. ${routes.skills} lists guidance.`;
|
|
39
39
|
}
|
|
40
40
|
/** True when at least one of `connectors` carries a usage guide. */
|
|
41
41
|
export function hasConnectorGuides(connectors) {
|
|
@@ -27,7 +27,7 @@ import { Effect } from "effect";
|
|
|
27
27
|
import { activityBehaviorFacts, activityPackageVersion, activityClientFact, InvalidActivityCursorError, } from "../activity.js";
|
|
28
28
|
import { assertKnownOptions, ConfigError, keys, optionsOf } from "../config-schema.js";
|
|
29
29
|
import { agentFrictionForCode } from "../activity-friction.js";
|
|
30
|
-
import { validateStorageKey } from "./keys.js";
|
|
30
|
+
import { accessTokenKeys, validateStorageKey } from "./keys.js";
|
|
31
31
|
const sql = (text, ...params) => ({
|
|
32
32
|
sql: text,
|
|
33
33
|
params,
|
|
@@ -107,6 +107,31 @@ export function nativeSqlStorage(driver) {
|
|
|
107
107
|
return yield* current(key);
|
|
108
108
|
});
|
|
109
109
|
},
|
|
110
|
+
accessToken(hash) {
|
|
111
|
+
return Effect.gen(function* () {
|
|
112
|
+
const key = accessTokenKeys.lookup(hash);
|
|
113
|
+
yield* ensure();
|
|
114
|
+
// The links select candidate rows only. Admission still parses every
|
|
115
|
+
// raw value and addresses it by its physical key, so JSON extraction
|
|
116
|
+
// differences (e.g. duplicate fields) cannot substitute another slot.
|
|
117
|
+
// json_valid guards malformed stored JSON; expiry uses the DB clock.
|
|
118
|
+
const rows = yield* driver.all(sql(`WITH lookup AS (
|
|
119
|
+
SELECT key, value FROM connecta_kv WHERE key = ? AND ${live}
|
|
120
|
+
), record AS (
|
|
121
|
+
SELECT key, value FROM connecta_kv WHERE key = ? ||
|
|
122
|
+
(SELECT CASE WHEN json_valid(value) THEN json_extract(value, '$.id') END FROM lookup)
|
|
123
|
+
AND ${live}
|
|
124
|
+
), slot AS (
|
|
125
|
+
SELECT key, value FROM connecta_kv WHERE key = ? ||
|
|
126
|
+
(SELECT CASE WHEN json_valid(value) THEN json_extract(value, '$.agent') END FROM record)
|
|
127
|
+
AND ${live}
|
|
128
|
+
)
|
|
129
|
+
SELECT key, ${textColumn("value")} FROM lookup
|
|
130
|
+
UNION ALL SELECT key, ${textColumn("value")} FROM record
|
|
131
|
+
UNION ALL SELECT key, ${textColumn("value")} FROM slot`, key, accessTokenKeys.recordPrefix, accessTokenKeys.agent("")));
|
|
132
|
+
return new Map(rows.map((row) => [row.key, textOf(row.value, row.value_bytes, "Stored access token metadata")]));
|
|
133
|
+
});
|
|
134
|
+
},
|
|
110
135
|
getMany(keys) {
|
|
111
136
|
return Effect.gen(function* () {
|
|
112
137
|
for (const key of keys)
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CONNECTA_VERSION = "0.
|
|
1
|
+
export declare const CONNECTA_VERSION = "0.40.0";
|
package/dist/version.js
CHANGED
|
@@ -340,7 +340,7 @@ without storage reads, decryption or hashing. Catalog invalidation (including
|
|
|
340
340
|
operator refresh and OAuth connect/disconnect/restart) advances a counter for the
|
|
341
341
|
registry's cache store; built-in vault set/clear advances a counter for that vault.
|
|
342
342
|
These weakly held counters contain no catalogs, credentials or caller identity;
|
|
343
|
-
catalogs and derived views remain request-local. A moved fence replaces the
|
|
343
|
+
registry catalogs and derived views remain request-local. A moved fence replaces the
|
|
344
344
|
retained read and its search views, and prevents joining an older registry flight.
|
|
345
345
|
Changes made through another process/isolate or directly in storage need not be
|
|
346
346
|
observed mid-request; the next request uses the durable generation and auth checks.
|
|
@@ -619,6 +619,18 @@ Only complete, intake-redacted tools/list, resources/list and
|
|
|
619
619
|
resources/templates/list successes are stored. Resource payloads are not cached. SDK default
|
|
620
620
|
in-memory caching is replaced, so raw catalogs never enter it.
|
|
621
621
|
|
|
622
|
+
A warm isolate retains at most four validated stored JSON catalogs, with a
|
|
623
|
+
combined accounting budget of 8 MiB (twice serialized input bytes, key bytes and
|
|
624
|
+
64 bytes per object property/array slot; larger entries bypass reuse).
|
|
625
|
+
These deeply frozen facts contain no request context, transport, signal,
|
|
626
|
+
callback or promise, and require neither weak references nor cleanup timers.
|
|
627
|
+
Reuse follows the same manifest TTL checks, complete chunk reads, recomputed
|
|
628
|
+
SHA-256 fingerprint and fresh generation/credential acceptance fence as a cold
|
|
629
|
+
read. Its key binds the stored entry, current private owner/deployment partition
|
|
630
|
+
(even for public entries), and chunk fingerprint. Request-specific credential
|
|
631
|
+
redaction and TTL intake still run every time. Eviction affects performance
|
|
632
|
+
only; SQL remains authoritative.
|
|
633
|
+
|
|
622
634
|
Stored catalog and negotiation keys use deployment-keyed partitions. A remote
|
|
623
635
|
connector's whole cache configuration and negotiation input enter a stored key
|
|
624
636
|
only through HMAC-SHA-256 (`src/storage-digest.ts`) under a key that never
|
|
@@ -643,6 +655,28 @@ refresh baselines within 48 hours, verdicts within six hours), so each remote
|
|
|
643
655
|
connector's partitions refill once after the upgrade with one handshake and
|
|
644
656
|
listing.
|
|
645
657
|
|
|
658
|
+
Warm remote resolution batches the negotiation verdict and locating generation
|
|
659
|
+
when their physical keys share an observed SQL store. Private and public
|
|
660
|
+
manifest probes share a `getMany` statement; their normal precedence and public
|
|
661
|
+
scope check still apply. These batches contain only reads. Catalog addresses
|
|
662
|
+
use the listing's pinned OAuth identity and locating generation; they authorize
|
|
663
|
+
nothing. After chunk verification, acceptance re-reads the OAuth identity and
|
|
664
|
+
generation separately and refuses a changed epoch or token digest. The invocation
|
|
665
|
+
gate checks disconnect first, then reads tokens separately to refuse absent
|
|
666
|
+
credentials and observe current expiry and the invalidation fingerprint. Token
|
|
667
|
+
removal, expiry, scope and replacement can change without advancing the epoch;
|
|
668
|
+
epoch fences alone cannot protect these values. The SDK's later token read may
|
|
669
|
+
return no tokens and omit Authorization, so it does not replace invocation's
|
|
670
|
+
token-presence refusal. The transport also refuses tokenless MCP requests on
|
|
671
|
+
passive OAuth paths, regardless of provider history; an explicit authorization
|
|
672
|
+
start may probe for the SDK's challenge. A refused tool send binds its private
|
|
673
|
+
failure provenance to the undispatched physical request. Invocation accepts it
|
|
674
|
+
only for the owning boundary and whole logical call; error fields, copied
|
|
675
|
+
connectors, and refusals after a send cannot supply that proof.
|
|
676
|
+
Connection reuse and SDK dispatch retain their fresh
|
|
677
|
+
epoch or token reads; no credential read is prefetched.
|
|
678
|
+
Stores with replaced methods or separate backing stores retain individual reads.
|
|
679
|
+
|
|
646
680
|
A remote connector's `listTools` without a live connection in its request
|
|
647
681
|
reads this cache before connecting. It derives the same partition a connect
|
|
648
682
|
binds (the admitted principal, the caller's credential digest, or the owner's
|
package/documentation/auth.md
CHANGED
|
@@ -651,6 +651,11 @@ agent's slot is `issued` naming exactly that record. Nothing else counts: a
|
|
|
651
651
|
lookup or record the slot does not name is inert garbage. Every mutation
|
|
652
652
|
commits by one `compareAndSet` on the slot and only moves forward; no security
|
|
653
653
|
outcome depends on cleanup or on undoing a write.
|
|
654
|
+
Admission reads this state afresh on every request; successful token verdicts
|
|
655
|
+
are never cached. D1 and SQLite read the lookup, record, and slot in one
|
|
656
|
+
statement, with the same validation and expiry checks. Custom storage adapters
|
|
657
|
+
retain the sequential reads. Rotation and revocation therefore retain their
|
|
658
|
+
next-request refusal bound.
|
|
654
659
|
|
|
655
660
|
- **Rotate** writes the new record and its lookup (inert), checks the
|
|
656
661
|
request's signal, then compare-and-sets the slot from what it read to
|
|
@@ -1345,6 +1350,16 @@ code, so `remoteMcp()` refuses it as personal at construction.
|
|
|
1345
1350
|
|
|
1346
1351
|
## Downstream OAuth state at rest
|
|
1347
1352
|
|
|
1353
|
+
An OAuth remote MCP connector requires an access token on passive MCP requests,
|
|
1354
|
+
including handshakes, catalog requests, and tool dispatches, even if the server
|
|
1355
|
+
accepts anonymous requests. This requirement survives provider replacement.
|
|
1356
|
+
If the SDK cannot obtain a token at send time, Connecta returns OAuth recovery
|
|
1357
|
+
before sending. A locally refused write counts as failed, without reconciliation;
|
|
1358
|
+
a write that may already have left remains unknown and is never replayed.
|
|
1359
|
+
Only an explicit authorization start may probe the MCP endpoint without a token
|
|
1360
|
+
for the SDK's challenge, never to call a tool. Authorization-server discovery,
|
|
1361
|
+
client registration, token exchange, and refresh retain their OAuth flows.
|
|
1362
|
+
|
|
1348
1363
|
Each owner of an OAuth connector (the deployment when shared, each principal
|
|
1349
1364
|
when personal) has one **grant record**, `oauth:grant` in the connector's
|
|
1350
1365
|
namespace: the live **epoch**, the latest consent's state digest, and one
|
|
@@ -1432,7 +1447,7 @@ with the gate a call passes:
|
|
|
1432
1447
|
| Stored grant | Status | Start (Connect, Continue, Restart, `authorize_connector`) |
|
|
1433
1448
|
| ------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1434
1449
|
| Disconnected | `auth_required`; the downstream is not asked | Replaces the epoch, then proceeds as for no tokens |
|
|
1435
|
-
| No tokens |
|
|
1450
|
+
| No tokens | `auth_required`; tokenless MCP requests are refused locally | Connects first, so a 401's `resource_metadata` starts consent as before. A downstream that answers without credentials still gets consent, discovered from RFC 9728 metadata at the configured URL's well-known locations. The start answers `auth_required` with the consent URL. |
|
|
1436
1451
|
| Tokens (any expiry) | Connects; `ok`, or `auth_required` when a 401 needs consent | Connects; `ok` without starting a flow when healthy |
|
|
1437
1452
|
|
|
1438
1453
|
A start without a challenge runs the same SDK flow as a 401 without
|
|
@@ -345,8 +345,8 @@ check shared behavior. Provider-specific tests remain beside each provider.
|
|
|
345
345
|
|
|
346
346
|
Use [`@zackbart/connecta-eve`](https://github.com/zackbart/connecta/blob/main/packages/connecta-eve/README.md)
|
|
347
347
|
in the Eve app for an app-owned connection and endpoint instructions refreshed
|
|
348
|
-
once per turn. It
|
|
349
|
-
|
|
348
|
+
once per turn. It installs separately from Connecta. Follow its README for the
|
|
349
|
+
supported Node and Eve versions, the two agent files, token environment variables,
|
|
350
350
|
optional write approval and Vercel OIDC token source. Configure the server's
|
|
351
351
|
[agent and pool membership](./auth.md#issue-rotate-or-revoke-an-agent-token)
|
|
352
352
|
before connecting.
|
|
@@ -905,6 +905,16 @@ fulfill downstream input.
|
|
|
905
905
|
|
|
906
906
|
## Authorization recovery
|
|
907
907
|
|
|
908
|
+
Every native pool lists `authorize_connector`, including pools without code.
|
|
909
|
+
If a connector’s tools are missing, use its id from that tool’s granted-connector
|
|
910
|
+
list to request a typed handoff. Give the URL and instructions to the operator;
|
|
911
|
+
after repair the host must refresh its tool list before it can call the repaired
|
|
912
|
+
tools (the cache hint is 300 seconds). A host that keeps its first list needs a
|
|
913
|
+
reconnect or a new conversation; on a pool with `execute_code`, a program can call
|
|
914
|
+
them by address without one.
|
|
915
|
+
Listing never creates a handoff. Authorization accepts only connectors already
|
|
916
|
+
granted to this request; it cannot enable a connector or expand pool scope.
|
|
917
|
+
|
|
908
918
|
On MCP 2026-07-28, a host declaring `elicitation.url` receives
|
|
909
919
|
`resultType: "input_required"` when an eligible call or program needs recoverable
|
|
910
920
|
connector authentication and the admitted identity may manage that connector.
|
|
@@ -1437,10 +1447,10 @@ pool's surface.
|
|
|
1437
1447
|
| Each visible downstream tool | Named `<connector>__<tool>`; object input constraints and local references preserved; no `outputSchema`; `_meta["dev.connecta/address"]` is its address |
|
|
1438
1448
|
| `execute_code` | Unless `executeCode: false`. Each downstream tool's description then ends with `Program address: <connector>.<tool>`, the address `connecta.call` takes |
|
|
1439
1449
|
| `read_result` | Only with `executeCode: false`: `{ id, offset?, page?, maxBytes? }` pages a stashed result exactly as `connecta.result` does, with the same bindings and rechecks |
|
|
1450
|
+
| `authorize_connector` | Always; requests the existing typed authorization handoff for a granted connector |
|
|
1440
1451
|
| `skills` | Always |
|
|
1441
1452
|
|
|
1442
|
-
`search_tools`, `call_tool`, `call_destructive_tool
|
|
1443
|
-
are not listed. Auth failures still carry their handoff in the error envelope.
|
|
1453
|
+
`search_tools`, `call_tool`, and `call_destructive_tool` are not listed. Auth failures still carry their handoff in the error envelope.
|
|
1444
1454
|
|
|
1445
1455
|
**Names.** A name is a pure function of the connector id and downstream tool
|
|
1446
1456
|
name, so it survives catalog refreshes. It is `<connector>__<tool>` when the
|
|
@@ -1484,7 +1494,7 @@ window, a native `tools/list` carries a 300-second private cache hint, against
|
|
|
1484
1494
|
an hour on meta endpoints. This hint does not guarantee that a host refreshes
|
|
1485
1495
|
its cached annotations; the stale-approval race is accepted on writable pools.
|
|
1486
1496
|
|
|
1487
|
-
**Listing.** `tools/list` is one page with no cursor (Eve
|
|
1497
|
+
**Listing.** `tools/list` is one page with no cursor (Eve's MCP client reads only the
|
|
1488
1498
|
first page). A pool listing more than 1,000 tools fails `tools/list` with a
|
|
1489
1499
|
JSON-RPC error asking the operator to narrow the pool; nothing is silently
|
|
1490
1500
|
dropped. A connector whose catalog cannot load is left out of that listing
|
|
@@ -1498,7 +1508,14 @@ reduced calls; write tools are absent and refused on a read-only endpoint;
|
|
|
1498
1508
|
never repeat a write; page a truncated result as its notice says. A pool
|
|
1499
1509
|
`description` is appended as on meta endpoints.
|
|
1500
1510
|
|
|
1501
|
-
**Recovery.**
|
|
1511
|
+
**Recovery.** `authorize_connector` remains listed even when a catalog fails.
|
|
1512
|
+
Its description lists all granted connector ids independently of catalog health.
|
|
1513
|
+
Use it for a missing connector, then refresh the host tool list after repair.
|
|
1514
|
+
Endpoint instructions carry the same recovery rule for hosts that fetch them
|
|
1515
|
+
separately. A cached list retains the recovery tool; a refreshed list restores
|
|
1516
|
+
the repaired connector’s whole catalog.
|
|
1517
|
+
|
|
1518
|
+
Repair and validation fields retain the exact constraint details.
|
|
1502
1519
|
Discovery `nextAction` points to the host's tool search (`host: "search"`), with
|
|
1503
1520
|
only a `query` argument. Retry actions and retry text name the native tool,
|
|
1504
1521
|
never a meta wrapper; a program on a native pool sees the same native wording
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zackbart/connecta",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "One MCP to rule them all — a single MCP endpoint aggregating many downstream connectors behind a code-first surface of six meta-tools.",
|