@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 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
- "search_tools",
268
- "call_tool",
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
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs.
2
2
  // Workers-safe: no runtime filesystem read or deployment override.
3
- export const CONNECTA_VERSION = "0.39.0";
3
+ export const CONNECTA_VERSION = "0.40.0";
@@ -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 lookupRaw = (yield authStorage(this.storage, "get", [lookupKey(hash)]));
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 authStorage(this.storage, "get", [recordKey(lookup.id)]))
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 authStorage(this.storage, "get", [slotKey(record.agent)]));
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)
@@ -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(() => {
@@ -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 (resolveCredentialIdentity &&
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
- // Locating only: get's fence and set's publication re-check fresh.
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
- const m = manifest((yield authCall(io, [
643
- cacheMethod(() => authStorage(storage, "get", [root])),
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
- const items = catalogItems(method, result);
676
- if (!Array.isArray(items) ||
677
- items.length > MAX_CATALOG_TOOLS ||
678
- "nextCursor" in result ||
679
- (result.resultType !== undefined && result.resultType !== "complete"))
680
- return undefined;
681
- const validated = (yield authInvoke(catalogSchema(method)["~standard"], "validate", [result]));
682
- if (stopped() || validated.issues)
683
- return undefined;
684
- const clean = intake(ctx, method, validated.value);
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 ?? rt.newProvider(ctx, undefined, signal);
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]));
@@ -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
  }
@@ -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
  }
@@ -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
- export function knownProviderFailure(error) {
137
- if (error !== null && typeof error === "object")
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.39.0 (+https://github.com/zackbart/connecta)";
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.39.0";
1
+ export declare const CONNECTA_VERSION = "0.40.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated from package.json by scripts/generate-version.mjs.
2
2
  // Workers-safe: no runtime filesystem read or deployment override.
3
- export const CONNECTA_VERSION = "0.39.0";
3
+ export const CONNECTA_VERSION = "0.40.0";
@@ -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
@@ -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 | Connects; `auth_required` even when the downstream answers | 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. |
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 requires Node 24 and Eve 0.90.x, and installs separately from
349
- Connecta. Follow its README for the two agent files, token environment variables,
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`, and `authorize_connector`
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 0.90 reads only the
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.** Repair and validation fields retain the exact constraint details.
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.39.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.",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@clerk/backend": "^3.12.0",
21
- "@zackbart/connecta": "0.39.0",
21
+ "@zackbart/connecta": "0.40.0",
22
22
  "quickjs-emscripten": "0.32.0",
23
23
  "undici": "6.29.1"
24
24
  },