@zackbart/connecta 0.38.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.
Files changed (180) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +3 -2
  3. package/bin/connecta.mjs +4 -8
  4. package/bin/version.mjs +1 -1
  5. package/dist/access-tokens.js +6 -4
  6. package/dist/agent-routes.js +231 -0
  7. package/dist/auth/downstream-oauth/consent.js +519 -0
  8. package/dist/auth/downstream-oauth/grant-store.js +439 -0
  9. package/dist/auth/downstream-oauth/refresh-coordinator.js +562 -0
  10. package/dist/auth/downstream-oauth/refresh-records.js +447 -0
  11. package/dist/auth/downstream-oauth/sdk-hooks.js +242 -0
  12. package/dist/auth/downstream-oauth.js +66 -2382
  13. package/dist/auth/native.js +37 -0
  14. package/dist/auth/oauth-grant.js +13 -0
  15. package/dist/auth/static-oauth.js +153 -131
  16. package/dist/auth-elicitation.js +7 -1
  17. package/dist/authorization-handoff.js +11 -5
  18. package/dist/catalog-cache.js +219 -59
  19. package/dist/catalog-policy.js +18 -0
  20. package/dist/catalog-service.js +284 -47
  21. package/dist/catalog.js +18 -2
  22. package/dist/config.d.ts +16 -11
  23. package/dist/config.js +9 -6
  24. package/dist/connector-caller.d.ts +1 -1
  25. package/dist/connector-health.js +59 -0
  26. package/dist/connector-status.js +117 -0
  27. package/dist/connectors/api-connector.d.ts +7 -2
  28. package/dist/connectors/api-connector.js +7 -4
  29. package/dist/connectors/negotiation-cache.js +22 -4
  30. package/dist/connectors/remote-mcp/call.js +164 -0
  31. package/dist/connectors/remote-mcp/catalog.js +278 -0
  32. package/dist/connectors/remote-mcp/config.js +263 -0
  33. package/dist/connectors/remote-mcp/connection.js +682 -0
  34. package/dist/connectors/remote-mcp/errors.js +488 -0
  35. package/dist/connectors/remote-mcp/oauth.js +473 -0
  36. package/dist/connectors/remote-mcp/resources.js +473 -0
  37. package/dist/connectors/remote-mcp/runtime.js +1 -0
  38. package/dist/connectors/remote-mcp/transport.js +555 -0
  39. package/dist/connectors/remote-mcp.js +62 -2863
  40. package/dist/credential-contract.d.ts +23 -0
  41. package/dist/credential-reads.js +17 -0
  42. package/dist/credential-verification.js +53 -0
  43. package/dist/credentials.d.ts +7 -0
  44. package/dist/credentials.js +60 -25
  45. package/dist/describe-config.d.ts +12 -2
  46. package/dist/describe-config.js +2 -0
  47. package/dist/discovery-health.js +199 -0
  48. package/dist/downstream-input.js +2 -0
  49. package/dist/errors.d.ts +55 -17
  50. package/dist/errors.js +95 -41
  51. package/dist/execute.js +152 -114
  52. package/dist/executor-result.js +3 -1
  53. package/dist/guest-functions.js +83 -0
  54. package/dist/guest-runtime.js +60 -19
  55. package/dist/guest-types.d.ts +19 -5
  56. package/dist/http/argument-problems.js +35 -0
  57. package/dist/http/errors.js +5 -4
  58. package/dist/http/fields.js +192 -13
  59. package/dist/http/hatch-response.js +73 -0
  60. package/dist/http/hatch.js +88 -23
  61. package/dist/http/oauth.js +3 -2
  62. package/dist/http/provider.js +32 -4
  63. package/dist/http/safety.js +8 -0
  64. package/dist/http/tools.js +44 -23
  65. package/dist/index.d.ts +1 -1
  66. package/dist/index.js +9 -2
  67. package/dist/invocation.js +26 -8
  68. package/dist/membership.d.ts +13 -5
  69. package/dist/membership.js +34 -2
  70. package/dist/meta-tools.js +54 -31
  71. package/dist/native-recovery.js +4 -16
  72. package/dist/native-tools.js +5 -2
  73. package/dist/oauth-sealing.js +10 -0
  74. package/dist/operation-vocabulary.js +135 -13
  75. package/dist/operator-record.d.ts +2 -2
  76. package/dist/operator-record.js +1 -0
  77. package/dist/operator-ui/contract.d.ts +10 -0
  78. package/dist/operator-ui/generated.js +2 -2
  79. package/dist/operator-ui/model.d.ts +22 -1
  80. package/dist/provider-call-facts.js +6 -2
  81. package/dist/providers/_shared/rest/operation-index.js +48 -50
  82. package/dist/providers/_shared/rest/value-safety.js +80 -0
  83. package/dist/providers/breeze/api.js +7 -1
  84. package/dist/providers/breeze/skill.generated.js +1 -1
  85. package/dist/providers/breeze/tools.js +4 -3
  86. package/dist/providers/ccb/api.js +2 -1
  87. package/dist/providers/ccb/index.js +5 -0
  88. package/dist/providers/ccb/skill.generated.js +1 -1
  89. package/dist/providers/cloudflare/api.js +8 -1
  90. package/dist/providers/cloudflare/skill.generated.js +1 -1
  91. package/dist/providers/docs/lib/schemas.js +24 -22
  92. package/dist/providers/docs/safety.js +2 -0
  93. package/dist/providers/docs/skill.generated.js +1 -1
  94. package/dist/providers/docs/tools.js +2 -4
  95. package/dist/providers/drive/api.js +1 -0
  96. package/dist/providers/drive/lib/contracts.js +8 -5
  97. package/dist/providers/drive/skill.generated.js +1 -1
  98. package/dist/providers/drive/tools.js +11 -16
  99. package/dist/providers/forms/api.js +1 -0
  100. package/dist/providers/forms/skill.generated.js +1 -1
  101. package/dist/providers/forms/tools.js +10 -7
  102. package/dist/providers/github/api.js +1 -0
  103. package/dist/providers/github/lib/discovery.js +1 -1
  104. package/dist/providers/github/skill.generated.js +1 -1
  105. package/dist/providers/github/tools.js +61 -17
  106. package/dist/providers/gmail/lib/schemas.js +60 -50
  107. package/dist/providers/gmail/skill.generated.js +1 -1
  108. package/dist/providers/gmail/tools.js +7 -5
  109. package/dist/providers/granola/index.js +2 -0
  110. package/dist/providers/granola/skill.generated.js +1 -1
  111. package/dist/providers/overflow/api.js +7 -1
  112. package/dist/providers/overflow/skill.generated.js +1 -1
  113. package/dist/providers/planning-center/api.js +3 -1
  114. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  115. package/dist/providers/planning-center/skill.generated.js +1 -1
  116. package/dist/providers/quickbooks/safety.js +80 -39
  117. package/dist/providers/quickbooks/skill.generated.js +1 -1
  118. package/dist/providers/sheets/api.js +1 -0
  119. package/dist/providers/sheets/skill.generated.js +1 -1
  120. package/dist/providers/sheets/tools.js +13 -14
  121. package/dist/providers/slides/skill.generated.js +1 -1
  122. package/dist/providers/slides/tools.js +15 -13
  123. package/dist/providers/stripe/skill.generated.js +1 -1
  124. package/dist/providers/tithely/api.js +3 -1
  125. package/dist/providers/tithely/skill.generated.js +1 -1
  126. package/dist/providers/vercel/api.js +1 -1
  127. package/dist/providers/vercel/skill.generated.js +1 -1
  128. package/dist/providers/vercel/tools.js +4 -3
  129. package/dist/registry.d.ts +10 -4
  130. package/dist/registry.js +41 -13
  131. package/dist/routes/credentials.js +129 -60
  132. package/dist/routes/dispatch.js +5 -1
  133. package/dist/routes/mcp.js +30 -14
  134. package/dist/routes/operator.js +2 -2
  135. package/dist/routes/shared.d.ts +6 -1
  136. package/dist/routes/shared.js +7 -1
  137. package/dist/routes/ui-config.js +2 -2
  138. package/dist/routes/ui-data.js +126 -20
  139. package/dist/routes/ui.js +16 -0
  140. package/dist/runtime/call.js +7 -2
  141. package/dist/runtime/native-storage.js +12 -0
  142. package/dist/runtime/request-reads.js +207 -0
  143. package/dist/runtime/request-scope.js +16 -0
  144. package/dist/runtime/storage.js +12 -0
  145. package/dist/skills.js +37 -20
  146. package/dist/storage/keys.js +53 -3
  147. package/dist/storage/memory.js +8 -2
  148. package/dist/storage/sql-native.js +53 -1
  149. package/dist/storage/sql.js +12 -2
  150. package/dist/storage-digest.js +32 -0
  151. package/dist/timeout.js +8 -0
  152. package/dist/types.d.ts +33 -0
  153. package/dist/usage-guide.js +16 -35
  154. package/dist/validate.d.ts +3 -0
  155. package/dist/validate.js +220 -158
  156. package/dist/version.d.ts +1 -1
  157. package/dist/version.js +1 -1
  158. package/dist/write-outcome.js +0 -3
  159. package/documentation/README.md +13 -13
  160. package/documentation/architecture.md +385 -20
  161. package/documentation/auth.md +233 -64
  162. package/documentation/code-mode.md +122 -39
  163. package/documentation/deploying.md +6 -3
  164. package/documentation/integrating.md +16 -2
  165. package/documentation/meta-tools.md +240 -49
  166. package/documentation/migration-0.33.md +5 -1
  167. package/documentation/migration-0.34.md +3 -1
  168. package/documentation/operator-tests.md +1 -1
  169. package/documentation/operator-ui.md +105 -12
  170. package/documentation/pool-membership-upgrade.md +44 -0
  171. package/documentation/providers.md +105 -0
  172. package/examples/worker/AGENTS.md +10 -10
  173. package/examples/worker/README.md +4 -3
  174. package/examples/worker/src/connecta.config.ts +1 -0
  175. package/guides/connecting-agents.md +2 -1
  176. package/guides/how-it-works.md +4 -2
  177. package/package.json +1 -1
  178. package/templates/node/AGENTS.md +13 -12
  179. package/templates/node/package.json +1 -1
  180. package/templates/node/src/connecta.config.ts +1 -0
package/dist/config.js CHANGED
@@ -69,8 +69,10 @@ const discovery = {
69
69
  /**
70
70
  * Deadline (ms) for each downstream probe/catalog call fanned out by
71
71
  * `search_tools` and by `connecta.search`/`connecta.describe` inside
72
- * `execute_code`. Defaults to 30_000. A timed-out connector degrades
73
- * independently; this does not apply to tool calls. Catalog walks receive the
72
+ * `execute_code`. Defaults to 30_000. An unscoped search caps each probe at
73
+ * 5_000 and its whole fan-out at 9_000, and skips a connector whose
74
+ * discovery recently timed out for up to 60 seconds. A timed-out connector
75
+ * degrades independently; this does not apply to tool calls. Catalog walks receive the
74
76
  * same cancellation signal, which aborts an in-flight page where supported and
75
77
  * prevents another from starting.
76
78
  */
@@ -237,8 +239,9 @@ const pool = {
237
239
  /**
238
240
  * Who may open `/mcp/<name>`: principal ids in the namespace of the one
239
241
  * interactive auth provider, `{ namespace, id }`, or `"agent:<name>"` for a
240
- * declared agent. Required and non-empty; the pool serves exactly its
241
- * `tools` to these members and the same 404 to everyone else.
242
+ * declared agent. All members must be people or all must be agents. Required
243
+ * and non-empty; the pool serves exactly its `tools` to these members and
244
+ * the same 404 to everyone else.
242
245
  */
243
246
  members: required(opaque({ check: assertMemberList }), (path) => `${path} is required: list who may open this pool, as principal ids, { namespace, id }, or "agent:<name>".`),
244
247
  /**
@@ -246,8 +249,8 @@ const pool = {
246
249
  * connector whose `subject` is a mapping function. Humans always act as
247
250
  * themselves through that mapping, and a fixed `subject` address applies to
248
251
  * everyone. Without an owner, a mapping-subject Workspace connector is
249
- * absent for this pool's agents, as a personal connector is; people in the
250
- * pool still see it.
252
+ * absent for this pool's agents, as a personal connector is. People use a
253
+ * separate pool and always act as themselves.
251
254
  */
252
255
  owner: opaque({
253
256
  check: (value, path) => {
@@ -9,7 +9,7 @@ export interface ConnectorCaller {
9
9
  * with or without a subject, a Connecta-issued access token.
10
10
  */
11
11
  authenticated: boolean;
12
- /** The pool the call arrived on, at `/mcp/<pool>` or `/agent/<pool>`. */
12
+ /** The admitted pool: routed at `/mcp` or selected at `/mcp/<pool>` or `/agent/<pool>`. */
13
13
  pool?: string;
14
14
  /**
15
15
  * That pool's configured `owner`, the Workspace address an agent acts as
@@ -0,0 +1,59 @@
1
+ // Where one connector's bounded health facts are stored for a registry view:
2
+ // the catalog cache's store and owner partition (principal, subject, caller
3
+ // identity and pool, origin) and the admitted endpoint, with the connector's
4
+ // described configuration and the package version, through the deployment's
5
+ // keyed digest (a described endpoint keeps its path), beside the catalog
6
+ // invalidation generation those facts must match (INV-4, INV-5). Shared by
7
+ // discovery health and the operator page's status observations.
8
+ import { Effect } from "effect";
9
+ import { catalogOwnerPartition } from "./catalog-policy.js";
10
+ import { canonicalJson } from "./canonical-json.js";
11
+ import { secretDigest } from "./storage-digest.js";
12
+ import { responseCacheKeys } from "./storage/keys.js";
13
+ import { CONNECTA_VERSION } from "./version.js";
14
+ // A connector's described configuration, serialized once per connector object.
15
+ // A change `describe()` cannot see, such as a static header rotated in
16
+ // deployment code, takes effect on redeploy and leaves an earlier record to
17
+ // lapse within its TTL.
18
+ const configs = new WeakMap();
19
+ function describedConfig(connector) {
20
+ let config = configs.get(connector);
21
+ if (config === undefined) {
22
+ let described = null;
23
+ try {
24
+ described = connector.describe?.() ?? null;
25
+ }
26
+ catch {
27
+ described = null;
28
+ }
29
+ config = canonicalJson([CONNECTA_VERSION, connector.kind ?? null, connector.authScope ?? null, described]);
30
+ configs.set(connector, config);
31
+ }
32
+ return config;
33
+ }
34
+ // The endpoint a request was admitted at (plain `/mcp`, a pool, an agent
35
+ // path). The same person and pool can reach one connector through several,
36
+ // and each keeps its own records. The operator page binds none.
37
+ const endpoints = new WeakMap();
38
+ /** Bind the admitted endpoint to a request scope, before any discovery runs in it. */
39
+ export function bindDiscoveryEndpoint(requestScope, endpoint) {
40
+ endpoints.set(requestScope, endpoint);
41
+ }
42
+ /** Where one connector's records sit in this view's owner partition. */
43
+ export function healthAddress(registry, id, baseUrl, requestScope) {
44
+ return Effect.suspend(() => {
45
+ const connector = registry.getConnector(id);
46
+ if (!connector)
47
+ return Effect.succeed(undefined);
48
+ const ctx = registry.contextFor(id, baseUrl, requestScope);
49
+ const { storage, partition } = catalogOwnerPartition(ctx);
50
+ if (!storage)
51
+ return Effect.succeed(undefined);
52
+ return Effect.promise(async () => ({
53
+ storage,
54
+ digest: await secretDigest(ctx, JSON.stringify([partition, endpoints.get(requestScope) ?? null, describedConfig(connector)])),
55
+ generationKey: responseCacheKeys.generation(id),
56
+ logger: ctx.logger,
57
+ }));
58
+ }).pipe(Effect.catchCause(() => Effect.succeed(undefined)));
59
+ }
@@ -0,0 +1,117 @@
1
+ // The operator page's stored connector status (#1173 item 2.3).
2
+ //
3
+ // A live detail read records what it saw, one observation per connector and
4
+ // viewer partition, beside discovery health in the connector-health family and
5
+ // under the same owner partition and catalog generation (INV-4, INV-5). The
6
+ // detail route can then answer at once from storage, labelled with its age,
7
+ // and the browser asks for a live read separately. An observation holds only
8
+ // a state, a problem code, a tool count and its time (INV-6); the tools stay
9
+ // in the complete catalog cache (INV-8). It is never deferred work: a live
10
+ // read writes it before its response, and nothing refreshes it afterwards.
11
+ import { Effect } from "effect";
12
+ import { healthAddress } from "./connector-health.js";
13
+ import { failureRecord, logFailure } from "./operator-record.js";
14
+ import { causeError } from "./runtime/run.js";
15
+ import { Storage } from "./runtime/services.js";
16
+ import { requestReads } from "./runtime/request-scope.js";
17
+ import { storageGet, storageGetMany, storageSet } from "./runtime/storage.js";
18
+ import { connectorHealthKeys } from "./storage/keys.js";
19
+ /** How long the operator page shows a stored status before only a live read will do. */
20
+ const STATUS_OBSERVATION_TTL_MS = 120_000;
21
+ const STATUS_STATES = new Set([
22
+ "ok",
23
+ "per_caller",
24
+ "auth_required",
25
+ "credential_required",
26
+ "error",
27
+ ]);
28
+ const STATUS_PROBLEMS = new Set([
29
+ "connector_unavailable",
30
+ "connector_timeout",
31
+ "oauth_required",
32
+ "credential_required",
33
+ "auth_required",
34
+ "caller_refused",
35
+ "credential_mismatch",
36
+ "catalog_failed",
37
+ ]);
38
+ /**
39
+ * This viewer's current observation of one connector, and the slot a live
40
+ * read writes its own to when there is none. A record from another generation, past its TTL or
41
+ * of an unknown shape is absent. A storage failure is absent too.
42
+ */
43
+ export function readStatusObservation(registry, id, baseUrl, requestScope) {
44
+ return Effect.gen(function* () {
45
+ const address = yield* healthAddress(registry, id, baseUrl, requestScope);
46
+ if (!address)
47
+ return {};
48
+ const key = connectorHealthKeys.status(id, address.digest);
49
+ const values = yield* storageGetMany([key, address.generationKey]).pipe(Effect.provideService(Storage, address.storage), Effect.catchCause((cause) => Effect.sync(() => {
50
+ logFailure(address.logger, "discovery health read failed", failureRecord({}, causeError(cause)));
51
+ return undefined;
52
+ })));
53
+ if (!values)
54
+ return {};
55
+ const slot = { storage: address.storage, key, logger: address.logger, generation: values[1] ?? null };
56
+ try {
57
+ const value = JSON.parse(values[0] ?? "null");
58
+ if (value?.v === 1 &&
59
+ value.generation === slot.generation &&
60
+ typeof value.state === "string" &&
61
+ STATUS_STATES.has(value.state) &&
62
+ (value.problem === undefined || STATUS_PROBLEMS.has(value.problem)) &&
63
+ Number.isSafeInteger(value.toolCount) &&
64
+ value.toolCount >= 0 &&
65
+ Number.isFinite(value.observedAt) &&
66
+ Date.now() - value.observedAt < STATUS_OBSERVATION_TTL_MS)
67
+ return {
68
+ slot,
69
+ observation: {
70
+ state: value.state,
71
+ ...(value.problem ? { problem: value.problem } : {}),
72
+ toolCount: value.toolCount,
73
+ observedAt: value.observedAt,
74
+ },
75
+ };
76
+ }
77
+ catch {
78
+ /* An unreadable record is absent. */
79
+ }
80
+ return { slot };
81
+ });
82
+ }
83
+ /**
84
+ * Where a live read will record its observation, without reading the stored
85
+ * one. The generation is read through the request's read memo: a live read's
86
+ * catalog listing locates its cache entry by that same key, so the listing
87
+ * reuses this read instead of paying another round trip. A generation is never
88
+ * reused once replaced, so a memoized value can only lapse an observation
89
+ * early, never keep one past an invalidation that preceded this request.
90
+ */
91
+ export function prepareStatusSlot(registry, id, baseUrl, requestScope) {
92
+ return Effect.gen(function* () {
93
+ const address = yield* healthAddress(registry, id, baseUrl, requestScope);
94
+ if (!address)
95
+ return undefined;
96
+ const generation = yield* requestReads(requestScope)
97
+ .read(address.storage, address.generationKey, storageGet(address.generationKey))
98
+ .pipe(Effect.provideService(Storage, address.storage), Effect.catchCause((cause) => Effect.sync(() => {
99
+ logFailure(address.logger, "discovery health read failed", failureRecord({}, causeError(cause)));
100
+ return undefined;
101
+ })));
102
+ if (generation === undefined)
103
+ return undefined;
104
+ return {
105
+ storage: address.storage,
106
+ key: connectorHealthKeys.status(id, address.digest),
107
+ logger: address.logger,
108
+ generation,
109
+ };
110
+ });
111
+ }
112
+ /** Record what a live read saw. A failed write is logged; the page already has its answer. */
113
+ export function storeStatusObservation(slot, observation) {
114
+ return storageSet(slot.key, JSON.stringify({ v: 1, ...observation, generation: slot.generation }), {
115
+ expiresAtMs: Math.ceil(observation.observedAt + STATUS_OBSERVATION_TTL_MS),
116
+ }).pipe(Effect.provideService(Storage, slot.storage), Effect.catchCause((cause) => Effect.sync(() => logFailure(slot.logger, "discovery health write failed", failureRecord({}, causeError(cause))))));
117
+ }
@@ -204,8 +204,8 @@ export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, u
204
204
  credential: import("../config-schema.js").Field<unknown, unknown, true> & {
205
205
  readonly shape: {
206
206
  fields: import("../config-schema.js").Field<unknown[], readonly unknown[] | undefined, true>;
207
- label: import("../config-schema.js").Field<unknown, unknown, true>;
208
207
  description: import("../config-schema.js").Field<unknown, unknown, true>;
208
+ label: import("../config-schema.js").Field<unknown, unknown, true>;
209
209
  placeholder: import("../config-schema.js").Field<unknown, unknown, true>;
210
210
  };
211
211
  };
@@ -228,9 +228,9 @@ export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, u
228
228
  };
229
229
  tools: import("../config-schema.js").Field<unknown[], readonly unknown[] | undefined, true>;
230
230
  title: import("../config-schema.js").Field<unknown, unknown, true>;
231
+ description: import("../config-schema.js").Field<unknown, unknown, true>;
231
232
  authScope: import("../config-schema.js").Field<unknown, unknown, true>;
232
233
  maxResultBytes: import("../config-schema.js").Field<unknown, unknown, true>;
233
- description: import("../config-schema.js").Field<unknown, unknown, true>;
234
234
  testCredential: import("../config-schema.js").Field<unknown, unknown, true>;
235
235
  testCredentials: import("../config-schema.js").Field<unknown, unknown, true>;
236
236
  validateArgs: import("../config-schema.js").Field<unknown, unknown, true>;
@@ -244,6 +244,11 @@ export declare const API_OPTIONS: import("../config-schema.js").Field<unknown, u
244
244
  */
245
245
  export interface ApiOAuthHooks {
246
246
  status(ctx: ConnectorContext): Promise<ConnectorStatus>;
247
+ /**
248
+ * `status` for a call about to enter a handler, which also renews a token
249
+ * within a minute of its stored expiry. A failed renewal rejects.
250
+ */
251
+ prepareCall(ctx: ConnectorContext): Promise<ConnectorStatus>;
247
252
  startAuth(ctx: ConnectorContext, opts?: {
248
253
  force?: boolean;
249
254
  }): Promise<ConnectorStatus>;
@@ -1,3 +1,4 @@
1
+ import { declaresRouteDescriptions, routePhrases } from "../agent-routes.js";
1
2
  import { wrapViewCredentials } from "../runtime/call.js";
2
3
  import { storedAuthObservation, withStoredAuth } from "../auth/stored-observation.js";
3
4
  import { Effect, Exit } from "effect";
@@ -296,13 +297,15 @@ export function apiConnector(id, opts, oauth) {
296
297
  // This boundary's identity in a logical call, fixed now: this object with
297
298
  // this function, never whatever the property or a copy holds by then.
298
299
  providerBoundary(connector);
300
+ // Its catalog is authored here, so declared route descriptions follow each request's surface.
301
+ declaresRouteDescriptions(connector);
299
302
  registerCallEntry(connector, core);
300
303
  if (oauth)
301
304
  registerInvocationAuth(connector, async (ctx) => {
302
- // The built-in static OAuth status only reads the stored grant. It never
303
- // refreshes, invokes a handler, or sends a downstream request.
304
- if ((await settleOnClose(ctx, () => oauth.status(ctx))).state === "auth_required") {
305
- throw new ConnectorCallError("downstream_oauth_required", `Connector "${id}" requires authorization. Call authorize_connector.`);
305
+ // The stored grant, as status reads it. Only a token about to expire is
306
+ // renewed, before any handler runs; nothing is sent to the API.
307
+ if ((await settleOnClose(ctx, () => oauth.prepareCall(ctx))).state === "auth_required") {
308
+ throw new ConnectorCallError("downstream_oauth_required", `Connector "${id}" requires authorization. ${routePhrases(ctx).authorizeCall()}`);
306
309
  }
307
310
  });
308
311
  registerConnectorVocabulary(connector, opts.tools);
@@ -4,7 +4,8 @@ import { isSpecType } from "@modelcontextprotocol/client";
4
4
  import { failureRecord, logFailure } from "../operator-record.js";
5
5
  import { sentSecretsForRequest } from "../sent-secrets.js";
6
6
  import { assertDownstreamOutputSafe } from "../downstream-input-context.js";
7
- import { negotiationKeys, NEGOTIATION_TTL_SECONDS } from "../storage/keys.js";
7
+ import { LEGACY_NEGOTIATION_TTL_SECONDS, negotiationKeys, NEGOTIATION_TTL_SECONDS } from "../storage/keys.js";
8
+ import { viewReads } from "../runtime/call.js";
8
9
  /** Verdicts carry data only. Clients, signals and secrets remain request-local. */
9
10
  export function readNegotiation(ctx, digest) {
10
11
  return runEdge(authProgram(readNegotiationNative, [ctx, digest]));
@@ -12,7 +13,11 @@ export function readNegotiation(ctx, digest) {
12
13
  const readNegotiationNative = /* @__PURE__ */ registerAuth(readNegotiation, (ctx, digest) => authFlow(function* () {
13
14
  let prior;
14
15
  try {
15
- const raw = (yield authStorage(ctx.storage, "get", [negotiationKeys.verdict(digest)]));
16
+ // Memoized per request: a verdict only orders the versions a handshake
17
+ // tries, so one read earlier in this request at worst costs a fallback.
18
+ // This request's own storeNegotiation is a write, which clears the memo.
19
+ const key = negotiationKeys.verdict(digest);
20
+ const raw = (yield viewReads(ctx).read(ctx.storage, key, authStorage(ctx.storage, "get", [key])));
16
21
  if (!raw)
17
22
  return;
18
23
  const value = JSON.parse(raw);
@@ -40,17 +45,30 @@ const storeNegotiationNative = /* @__PURE__ */ registerAuth(storeNegotiation, (c
40
45
  const clean = sentSecretsForRequest(ctx.requestScope ?? ctx).redactCredentials(prior);
41
46
  if (clean.kind === "modern" && !isSpecType.DiscoverResult(clean.discover))
42
47
  return;
48
+ const ttlSeconds = clean.kind === "legacy" ? LEGACY_NEGOTIATION_TTL_SECONDS : NEGOTIATION_TTL_SECONDS;
43
49
  try {
44
50
  (yield authStorage(ctx.storage, "set", [
45
51
  negotiationKeys.verdict(digest),
46
52
  JSON.stringify({
47
53
  prior: clean,
48
- expiresAt: Date.now() + NEGOTIATION_TTL_SECONDS * 1000,
54
+ expiresAt: Date.now() + ttlSeconds * 1000,
49
55
  }),
50
- { ttlSeconds: NEGOTIATION_TTL_SECONDS },
56
+ { ttlSeconds },
51
57
  ]));
52
58
  }
53
59
  catch (error) {
54
60
  logFailure(ctx.logger, "negotiation cache write failed", failureRecord({}, error));
55
61
  }
56
62
  }));
63
+ /** Drop a verdict the downstream has contradicted, so the next connect negotiates. */
64
+ export function forgetNegotiation(ctx, digest) {
65
+ return runEdge(authProgram(forgetNegotiationNative, [ctx, digest]));
66
+ }
67
+ const forgetNegotiationNative = /* @__PURE__ */ registerAuth(forgetNegotiation, (ctx, digest) => authFlow(function* () {
68
+ try {
69
+ (yield authStorage(ctx.storage, "delete", [negotiationKeys.verdict(digest)]));
70
+ }
71
+ catch (error) {
72
+ logFailure(ctx.logger, "negotiation cache write failed", failureRecord({}, error));
73
+ }
74
+ }));
@@ -0,0 +1,164 @@
1
+ // tools/call: classification, write-once dispatch and the write's outcome
2
+ // when it fails.
3
+ import { authView } from "../../auth/native-call.js";
4
+ import { authFlow, authCall, authInvoke, authMethod, authCatch } from "../../auth/native.js";
5
+ import { ProtocolError, isInputRequiredResult, fromJsonSchema, UnauthorizedError, } from "@modelcontextprotocol/client";
6
+ import { Effect } from "effect";
7
+ import { requestSentSecrets } from "../../sent-secrets.js";
8
+ import { ConnectorCallError } from "../../errors.js";
9
+ import { carryFailureFacts, failureRecord } from "../../operator-record.js";
10
+ import { assertDownstreamOutputSafe } from "../../downstream-input-context.js";
11
+ import { callClassification } from "../../call-classification.js";
12
+ import { sourceClassification } from "../../tool-safety.js";
13
+ import { enterProviderBoundary, knownProviderFailure, providerRequest, vendorRefusal, } from "../../provider-call.js";
14
+ import { providerCallFailure, unsentAfterUnauthorized } from "../../write-outcome.js";
15
+ import { newDispatch, withDispatch } from "./transport.js";
16
+ import { downstreamCallError, hasSdkPayload, ownAbortReason, requiresAuthorization } from "./errors.js";
17
+ /** One connector's callTool operation, before the entry's wrappers. */
18
+ export function remoteMcpCall(rt) {
19
+ const { isOauth, id } = rt;
20
+ const callToolOperation = authMethod((name, args, ctx, options) => authFlow(function* () {
21
+ enterProviderBoundary(ctx, rt.connector);
22
+ let active;
23
+ try {
24
+ return (yield authView(ctx, authMethod(() => authFlow(function* () {
25
+ const state = rt.stateFor(ctx);
26
+ const client = (yield authCatch(authCall(rt.ensureConnected, [ctx, state]), (error) => {
27
+ throw knownProviderFailure(error);
28
+ }));
29
+ if (isOauth)
30
+ (yield authCatch(authCall(rt.renewBeforeDispatch, [ctx, state, client]), (error) => {
31
+ throw knownProviderFailure(error);
32
+ }));
33
+ const definition = options?.definition;
34
+ const facts = definition ?? state.toolDefinitions.get(name) ?? { name };
35
+ const sourceFacts = definition ?? {
36
+ name,
37
+ ...(facts.annotations
38
+ ? { annotations: facts.annotations }
39
+ : {}),
40
+ };
41
+ const source = sourceClassification(sourceFacts);
42
+ const serverWrite = source === "write";
43
+ const write = serverWrite || (callClassification(ctx) ?? definition?.classification ?? source) === "write";
44
+ const dispatch = newDispatch(write, serverWrite);
45
+ dispatch.request = {
46
+ entry: providerRequest(ctx, !write, `Connector "${id}"'s MCP server`, { method: "POST" }),
47
+ ctx,
48
+ };
49
+ active = { state, client, dispatch, write };
50
+ state.dispatches.set(dispatch, name);
51
+ const toolDefinition = definition
52
+ ? {
53
+ ...definition,
54
+ inputSchema: (definition.inputSchema ?? { type: "object" }),
55
+ }
56
+ : state.toolDefinitions.get(name);
57
+ if (toolDefinition?.execution?.taskSupport === "required") {
58
+ throw new ConnectorCallError("connector_call_failed", `Tool "${name}" requires task-based execution, which Connecta does not support.`);
59
+ }
60
+ let output;
61
+ const outputError = (code, message) => rt.atMcpBoundary(ctx, new ProtocolError(code, message), "tools/call", client.transport, [ctx.signal]);
62
+ if (toolDefinition?.outputSchema) {
63
+ try {
64
+ output = fromJsonSchema(toolDefinition.outputSchema);
65
+ }
66
+ catch {
67
+ throw outputError(-32602, `Tool '${name}' has an invalid outputSchema: schema could not be compiled`);
68
+ }
69
+ }
70
+ const result = (yield authCatch(authInvoke(client, "callTool", [
71
+ withDispatch({
72
+ name,
73
+ arguments: (args ?? {}),
74
+ ...options?.input,
75
+ }, dispatch),
76
+ {
77
+ ...rt.requestOptions(ctx),
78
+ allowInputRequired: true,
79
+ ...(toolDefinition
80
+ ? { toolDefinition: { ...toolDefinition, outputSchema: undefined } }
81
+ : write
82
+ ? { toolDefinition: { name, inputSchema: { type: "object" } } }
83
+ : {}),
84
+ },
85
+ ]), (err) => {
86
+ assertDownstreamOutputSafe(ctx.requestScope ?? ctx, err);
87
+ throw rt.atMcpBoundary(ctx, err, "tools/call", client.transport, [ctx.signal]);
88
+ }));
89
+ if (isInputRequiredResult(result)) {
90
+ return result;
91
+ }
92
+ assertDownstreamOutputSafe(ctx.requestScope ?? ctx, result);
93
+ if (output && !result.isError) {
94
+ if (result.structuredContent === undefined)
95
+ throw outputError(-32600, `Tool ${name} has an output schema but did not return structured content`);
96
+ const validation = (yield authInvoke(output["~standard"], "validate", [
97
+ result.structuredContent,
98
+ ]));
99
+ if (validation.issues)
100
+ throw outputError(-32602, "Structured content does not match the tool's output schema: validation failed");
101
+ }
102
+ return result;
103
+ }))));
104
+ }
105
+ catch (err) {
106
+ if (!active)
107
+ throw knownProviderFailure(err);
108
+ const { state, client, dispatch, write } = active;
109
+ const direct = write && callClassification(ctx) === undefined;
110
+ const ownAbort = ownAbortReason(err, [ctx.signal]) && !hasSdkPayload(err);
111
+ // The SDK can reject its wait synchronously from the abort event,
112
+ // before the physical response's Promise observer records its facts.
113
+ // Yield only settlement; no closed caller regains live authority.
114
+ if (ownAbort)
115
+ yield Effect.yieldNow;
116
+ if (ownAbort && !direct)
117
+ throw err;
118
+ if (requiresAuthorization(err) && state.client === client)
119
+ state.authRequired = true;
120
+ const failure = ownAbort
121
+ ? err
122
+ : err instanceof UnauthorizedError
123
+ ? carryFailureFacts(err, rt.authRequiredError(ctx))
124
+ : downstreamCallError(err, undefined, undefined, undefined, requestSentSecrets(ctx));
125
+ if (!write)
126
+ throw failure;
127
+ const renewal = dispatch.request?.entry.renewed
128
+ ? undefined
129
+ : dispatch.resendRefused
130
+ ? rt.authRequiredError(ctx)
131
+ : failure;
132
+ const unsent = (entry) => {
133
+ const verdict = unsentAfterUnauthorized(`Connector "${id}"'s MCP server`, renewal, isOauth ? "downstream_oauth_required" : "auth_required");
134
+ return entry ? vendorRefusal(verdict, entry) : verdict;
135
+ };
136
+ const verdict = dispatch.unauthorized
137
+ ? unsent(dispatch.request?.entry)
138
+ : dispatch.redirected || dispatch.resendRefused
139
+ ? new ConnectorCallError("connector_call_failed", `Connector "${id}"'s MCP server ` +
140
+ (dispatch.redirected
141
+ ? "answered this write with a redirect, and the write was not sent again, "
142
+ : "was sent this write, and a second send of it was stopped, ") +
143
+ "so whether it took effect is unknown: check the target before repeating it.", { retryable: false, writeOutcome: "unknown" })
144
+ : dispatch.ambiguous && failure instanceof ConnectorCallError
145
+ ? new ConnectorCallError(failure.code, `${failure.message} Connector "${id}"'s MCP server answered this write with an HTTP error ` +
146
+ "and no MCP error, so whether it took effect is unknown: check the target before repeating it.", { retryable: false, writeOutcome: "unknown" })
147
+ : failure;
148
+ const vendor = { title: `connector "${id}"` };
149
+ const status = failureRecord({}, err).httpStatus;
150
+ const settled = providerCallFailure(ctx, vendor, verdict, {
151
+ dispatched: dispatch.sent,
152
+ ...(status === undefined ? {} : { status }),
153
+ fallback: "write",
154
+ request: dispatch.request?.entry,
155
+ });
156
+ throw settled === failure ? failure : carryFailureFacts(failure, settled);
157
+ }
158
+ finally {
159
+ if (active)
160
+ active.state.dispatches.delete(active.dispatch);
161
+ }
162
+ }));
163
+ return { callToolOperation };
164
+ }