@zackbart/connecta 0.41.0 → 0.42.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 (148) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/bin/connecta.mjs +26 -17
  3. package/bin/meta-tool-names.d.mts +3 -1
  4. package/bin/meta-tool-names.mjs +4 -1
  5. package/bin/version.mjs +1 -1
  6. package/dist/activity.d.ts +2 -1
  7. package/dist/activity.js +5 -5
  8. package/dist/advertised-schema.js +15 -0
  9. package/dist/agent-routes.js +31 -5
  10. package/dist/auth/downstream-oauth/refresh-coordinator.js +4 -1
  11. package/dist/auth/downstream-oauth.js +3 -0
  12. package/dist/auth/static-oauth.js +26 -18
  13. package/dist/auth-elicitation.js +118 -61
  14. package/dist/authorization-handoff.js +36 -3
  15. package/dist/catalog-cache.js +292 -24
  16. package/dist/catalog-freshness.js +12 -0
  17. package/dist/catalog-service.js +373 -70
  18. package/dist/catalog.js +147 -58
  19. package/dist/config-defaults.d.ts +2 -1
  20. package/dist/config-defaults.js +4 -1
  21. package/dist/config.d.ts +43 -17
  22. package/dist/config.js +26 -10
  23. package/dist/connectors/remote-mcp/catalog.js +52 -28
  24. package/dist/connectors/remote-mcp/connection.js +62 -5
  25. package/dist/connectors/remote-mcp.js +10 -1
  26. package/dist/describe-config.d.ts +3 -2
  27. package/dist/discovery-health.js +26 -11
  28. package/dist/errors.d.ts +52 -3
  29. package/dist/errors.js +86 -3
  30. package/dist/execute.js +476 -200
  31. package/dist/executor-admission.js +2 -0
  32. package/dist/executor-contract.js +6 -0
  33. package/dist/executor-result.js +2 -2
  34. package/dist/executors/quickjs-child.js +13 -4
  35. package/dist/executors/quickjs-runtime.d.ts +2 -0
  36. package/dist/executors/quickjs-runtime.js +36 -19
  37. package/dist/executors/quickjs.js +23 -6
  38. package/dist/guest-functions.js +5 -4
  39. package/dist/guest-runtime.js +37 -1
  40. package/dist/guest-types.d.ts +18 -0
  41. package/dist/http/client.js +7 -2
  42. package/dist/http/errors.js +9 -4
  43. package/dist/http/fields.js +17 -5
  44. package/dist/http/hatch.js +9 -3
  45. package/dist/http/paging.js +1 -1
  46. package/dist/http/provider.js +2 -6
  47. package/dist/http/vendor.js +6 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +5 -0
  50. package/dist/invocation.js +98 -34
  51. package/dist/meta-output.js +24 -18
  52. package/dist/meta-tool-names.d.ts +1 -1
  53. package/dist/meta-tool-names.js +1 -1
  54. package/dist/meta-tools.js +259 -60
  55. package/dist/native-recovery.js +4 -1
  56. package/dist/native-tools.js +166 -51
  57. package/dist/node.js +3 -1
  58. package/dist/oauth-handoff.js +53 -43
  59. package/dist/operation-activity.js +2 -1
  60. package/dist/operation-vocabulary.js +6 -0
  61. package/dist/operator-record.d.ts +1 -1
  62. package/dist/operator-record.js +1 -0
  63. package/dist/operator-ui/generated.js +2 -2
  64. package/dist/program-result.js +2 -0
  65. package/dist/program-source.js +1 -1
  66. package/dist/program-writes.js +112 -19
  67. package/dist/provider.d.ts +0 -13
  68. package/dist/provider.js +0 -3
  69. package/dist/providers/_shared/google/kit.js +2 -2
  70. package/dist/providers/breeze/api.js +2 -2
  71. package/dist/providers/breeze/skill.generated.js +1 -1
  72. package/dist/providers/ccb/api.js +2 -2
  73. package/dist/providers/ccb/skill.generated.js +1 -1
  74. package/dist/providers/cloudflare/api.js +2 -2
  75. package/dist/providers/cloudflare/index.js +2 -0
  76. package/dist/providers/cloudflare/skill.generated.js +1 -1
  77. package/dist/providers/docs/skill.generated.js +1 -1
  78. package/dist/providers/drive/skill.generated.js +1 -1
  79. package/dist/providers/forms/index.js +1 -0
  80. package/dist/providers/forms/skill.generated.js +1 -1
  81. package/dist/providers/github/api.js +98 -6
  82. package/dist/providers/github/index.js +7 -2
  83. package/dist/providers/github/lib/auth.d.ts +3 -1
  84. package/dist/providers/github/lib/auth.js +43 -12
  85. package/dist/providers/github/lib/discovery.js +11 -1
  86. package/dist/providers/github/lib/operations.js +8 -0
  87. package/dist/providers/github/safety.js +39 -0
  88. package/dist/providers/github/skill.generated.js +1 -1
  89. package/dist/providers/github/tools.js +12 -5
  90. package/dist/providers/gmail/index.js +1 -0
  91. package/dist/providers/gmail/skill.generated.js +1 -1
  92. package/dist/providers/granola/skill.generated.js +1 -1
  93. package/dist/providers/overflow/index.js +2 -0
  94. package/dist/providers/overflow/skill.generated.js +1 -1
  95. package/dist/providers/planning-center/index.js +11 -5
  96. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  97. package/dist/providers/planning-center/skill.generated.js +1 -1
  98. package/dist/providers/quickbooks/index.js +2 -5
  99. package/dist/providers/quickbooks/skill.generated.js +1 -1
  100. package/dist/providers/sheets/index.js +1 -0
  101. package/dist/providers/sheets/skill.generated.js +1 -1
  102. package/dist/providers/slides/index.js +2 -2
  103. package/dist/providers/slides/skill.generated.js +1 -1
  104. package/dist/providers/stripe/skill.generated.js +1 -1
  105. package/dist/providers/tithely/index.js +1 -0
  106. package/dist/providers/tithely/skill.generated.js +1 -1
  107. package/dist/providers/vercel/api.js +2 -2
  108. package/dist/providers/vercel/skill.generated.js +1 -1
  109. package/dist/registry.d.ts +36 -4
  110. package/dist/registry.js +127 -27
  111. package/dist/result-preview.js +210 -0
  112. package/dist/result-seal.js +168 -0
  113. package/dist/route-names.js +13 -4
  114. package/dist/routes/connect.js +21 -13
  115. package/dist/routes/mcp.js +55 -26
  116. package/dist/routes/shared.d.ts +6 -0
  117. package/dist/runtime/call.js +15 -10
  118. package/dist/runtime/request-scope.js +36 -1
  119. package/dist/runtime/run.js +4 -1
  120. package/dist/sent-secrets.d.ts +20 -0
  121. package/dist/sent-secrets.js +49 -1
  122. package/dist/skills.js +100 -33
  123. package/dist/storage/keys.js +9 -6
  124. package/dist/storage/sql-native.js +1 -1
  125. package/dist/tool-safety.d.ts +33 -0
  126. package/dist/tool-safety.js +51 -15
  127. package/dist/types.d.ts +31 -0
  128. package/dist/usage-guide.js +14 -13
  129. package/dist/validate.js +29 -10
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker.js +28 -8
  133. package/documentation/architecture.md +104 -42
  134. package/documentation/auth.md +46 -19
  135. package/documentation/code-mode.md +378 -116
  136. package/documentation/deploying.md +10 -9
  137. package/documentation/hosted-mcp-migration.md +9 -3
  138. package/documentation/integrating.md +27 -5
  139. package/documentation/meta-tools.md +655 -153
  140. package/documentation/operating.md +5 -3
  141. package/documentation/providers.md +18 -6
  142. package/examples/worker/README.md +3 -3
  143. package/guides/how-it-works.md +2 -1
  144. package/guides/safety-and-trust.md +1 -3
  145. package/package.json +1 -1
  146. package/templates/node/AGENTS.md +3 -3
  147. package/templates/node/README.md +1 -1
  148. package/templates/node/package.json +1 -1
@@ -0,0 +1,168 @@
1
+ // A program's retained results are unreadable to any other request by default. Chunk 0,
2
+ // which holds the header a reader needs, stays in this memory-only ledger while the run
3
+ // lasts, so every way a run can end leaves nothing a reader can open unless sealing wrote
4
+ // that chunk. Sealing waits until the run can no longer add to its secret set, and only
5
+ // then writes the headers of entries that pass that set. Stored bytes never change, so an
6
+ // offset issued for an entry stays valid. A reclaimed transfer leaves this ledger first and
7
+ // gets no header. Sealing costs no extra writes, and reads stored bytes back only for
8
+ // entries the set grew past.
9
+ import { Duration, Effect } from "effect";
10
+ import { authStorage } from "./auth/native.js";
11
+ import { PendingWork } from "./runtime/request-scope.js";
12
+ import { withDeadlineEffect } from "./runtime/run.js";
13
+ import { resultKeys } from "./storage/keys.js";
14
+ // A BOM is payload here, as it is on every page of the same bytes.
15
+ const dec = new TextDecoder("utf-8", { ignoreBOM: true, fatal: false });
16
+ export const RESULT_ENVELOPE_V4 = "connecta-result-v4:";
17
+ /** Stored for an entry sealing did not publish, so its id can say why. Its binding and no bytes. */
18
+ const RESULT_REFUSED_V4 = "connecta-result-refused-v4:";
19
+ const SEAL_CONCURRENCY = 8;
20
+ /** Kept back from sealing's time for the markers and cleanup of unpublished entries. */
21
+ const REFUSAL_RESERVE_MS = 1000;
22
+ const envelope = (entry) => `${RESULT_ENVELOPE_V4}${entry.binding}:${entry.total}:${entry.chunkBytes}:${entry.cap}:${entry.first}`;
23
+ /** One run's unsealed results. It lives and dies with the run's request (INV-7). */
24
+ export class HeldResults {
25
+ secrets;
26
+ credentialReads;
27
+ entries = new Map();
28
+ hostCalls = new PendingWork();
29
+ closed = false;
30
+ constructor(secrets,
31
+ /** The request's credential reads, which outlast the host calls that started them. */
32
+ credentialReads) {
33
+ this.secrets = secrets;
34
+ this.credentialReads = credentialReads;
35
+ }
36
+ /** False once sealing began: a late entry would never become readable, and a late host call never starts. */
37
+ accepts() {
38
+ return !this.closed;
39
+ }
40
+ add(entry) {
41
+ if (this.closed)
42
+ return false;
43
+ this.entries.set(entry.id, entry);
44
+ return true;
45
+ }
46
+ /** Forget an entry whose stash is being given back, so sealing never writes its header.
47
+ * False once sealing began, when the entry is no longer this ledger's to give up. */
48
+ discard(id) {
49
+ return !this.closed && this.entries.delete(id);
50
+ }
51
+ /** Chunk 0 as storage will hold it, for paging inside the run that stashed it. */
52
+ stored(id) {
53
+ const entry = this.entries.get(id);
54
+ return entry && envelope(entry);
55
+ }
56
+ /** A guest host call; sealing waits for it to settle. */
57
+ track(work) {
58
+ const done = this.hostCalls.begin();
59
+ void work.then(done, done);
60
+ }
61
+ /** Stop accepting work and hand over what the run retained. */
62
+ close() {
63
+ this.closed = true;
64
+ const entries = [...this.entries.values()];
65
+ this.entries.clear();
66
+ return entries;
67
+ }
68
+ /** Succeeds once nothing the run started can add to the secret set. */
69
+ settled = Effect.suspend(() => this.hostCalls.idle).pipe(Effect.andThen(Effect.suspend(() => this.credentialReads.idle)), Effect.repeat({ until: () => !this.hostCalls.pending && !this.credentialReads.pending }), Effect.asVoid);
70
+ }
71
+ function bytesOf(encoded, total) {
72
+ const bytes = new Uint8Array(total);
73
+ let at = 0;
74
+ for (const part of encoded) {
75
+ const binary = atob(part);
76
+ for (let index = 0; index < binary.length; index++)
77
+ bytes[at++] = binary.charCodeAt(index);
78
+ }
79
+ return bytes;
80
+ }
81
+ /** The checks a result takes on its way into the stash, repeated over the whole stored value.
82
+ * Structured redaction joins text blocks, so a form split across two of them is found. */
83
+ function passesSet(secrets, text, format) {
84
+ if (format === "json") {
85
+ try {
86
+ if (secrets.changes(JSON.parse(text)))
87
+ return false;
88
+ }
89
+ catch {
90
+ // A value JSON renders as text, such as `undefined`, takes the text check alone.
91
+ }
92
+ }
93
+ return secrets.text(text) === text;
94
+ }
95
+ const SEALED = { refused: 0, refusedWrite: false };
96
+ /**
97
+ * Publish what can be published. Never fails. Nothing is published until the run's work
98
+ * has settled, and anything still pending when the budget ends leaves every entry
99
+ * unpublished, which is the refusal. Markers only improve what a later page is told, so
100
+ * a failed marker, a timeout or a cancellation changes nothing a reader can get.
101
+ */
102
+ export function sealHeldResults(held, storage,
103
+ /** The absolute time the run's response must be on its way. */
104
+ answerBy, signal) {
105
+ return Effect.suspend(() => {
106
+ const secrets = held.secrets;
107
+ const entries = held.close();
108
+ if (entries.length === 0)
109
+ return Effect.succeed(SEALED);
110
+ const published = new Set();
111
+ const key = (entry, index) => resultKeys.chunk(entry.id, index);
112
+ const passes = (entry) => Effect.gen(function* () {
113
+ const parts = [entry.first];
114
+ for (let index = 1; index < entry.chunks; index++) {
115
+ const chunk = yield* authStorage(storage, "get", [key(entry, index)]);
116
+ // Expired or evicted under a held header: nothing left to check, so nothing to offer.
117
+ if (chunk === null)
118
+ return false;
119
+ parts.push(chunk);
120
+ }
121
+ const text = dec.decode(bytesOf(parts, entry.total));
122
+ return passesSet(secrets, text, entry.format);
123
+ });
124
+ const seal = Effect.gen(function* () {
125
+ yield* held.settled;
126
+ const stable = secrets.revision;
127
+ yield* Effect.forEach(entries, (entry) => Effect.gen(function* () {
128
+ if (entry.passed !== stable && (entry.binary || !(yield* passes(entry))))
129
+ return;
130
+ // Nothing the run started is left, so this holds. If it ever fails, stop.
131
+ if (secrets.revision !== stable)
132
+ return;
133
+ yield* authStorage(storage, "set", [key(entry, 0), envelope(entry), { expiresAtMs: entry.expiresAtMs }]);
134
+ published.add(entry);
135
+ }).pipe(Effect.ignore), { concurrency: SEAL_CONCURRENCY, discard: true });
136
+ });
137
+ // A header write that commits after its timeout can replace a marker, so a marker is no
138
+ // publication fence. That write only ever carries bytes this seal already checked.
139
+ const refuse = (entry) => Effect.gen(function* () {
140
+ yield* authStorage(storage, "set", [
141
+ key(entry, 0),
142
+ `${RESULT_REFUSED_V4}${entry.binding}:`,
143
+ { expiresAtMs: entry.expiresAtMs },
144
+ ]);
145
+ for (let index = 1; index < entry.chunks; index++) {
146
+ yield* authStorage(storage, "delete", [key(entry, index)]);
147
+ }
148
+ }).pipe(Effect.ignore);
149
+ const remainingMs = Math.max(1, answerBy - Date.now());
150
+ const reserveMs = Math.min(REFUSAL_RESERVE_MS, Math.floor(remainingMs / 4));
151
+ return withDeadlineEffect(() => seal, {
152
+ timeoutMs: Math.max(1, remainingMs - reserveMs),
153
+ ...(signal ? { signal } : {}),
154
+ timeoutError: new Error("Sealing ran out of time."),
155
+ }).pipe(Effect.ignore, Effect.flatMap(() => {
156
+ const refused = entries.filter((entry) => !published.has(entry));
157
+ const outcome = refused.length
158
+ ? { refused: refused.length, refusedWrite: refused.some((entry) => entry.write) }
159
+ : SEALED;
160
+ if (refused.length === 0 || signal?.aborted)
161
+ return Effect.succeed(outcome);
162
+ return Effect.forEach(refused, refuse, { concurrency: SEAL_CONCURRENCY, discard: true }).pipe(Effect.timeoutOrElse({
163
+ duration: Duration.millis(Math.max(1, answerBy - Date.now())),
164
+ orElse: () => Effect.void,
165
+ }), Effect.as(outcome));
166
+ }));
167
+ });
168
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
- * `meta`: the six meta-tools. `code`: in-program wording, selected only by the
3
- * eval code arm. `native` and `native-code`: native pools without and with
4
- * `execute_code`.
2
+ * `meta`: the six meta-tools. `read-route`: those plus `execute_read_code`, which
3
+ * then takes read follow-ups, so they cost no write approval. `code`: in-program
4
+ * wording, selected only by the eval code arm. `native` and `native-code`: native
5
+ * pools without and with `execute_code`.
5
6
  */
6
7
  const NAMES = {
7
8
  meta: Object.freeze({
@@ -12,6 +13,14 @@ const NAMES = {
12
13
  authorize: "authorize_connector",
13
14
  skills: "skills",
14
15
  }),
16
+ "read-route": Object.freeze({
17
+ search: "search_tools",
18
+ read: "call_tool",
19
+ write: "call_destructive_tool",
20
+ program: "execute_read_code",
21
+ authorize: "authorize_connector",
22
+ skills: "skills",
23
+ }),
15
24
  code: Object.freeze({
16
25
  search: "connecta.search inside execute_code",
17
26
  read: "connecta.call inside execute_code",
@@ -37,7 +46,7 @@ const NAMES = {
37
46
  skills: "skills",
38
47
  }),
39
48
  };
40
- /** The six-tool surface's names: the default wherever no other surface applies. */
49
+ /** The meta surface's names: the default wherever no other surface applies. */
41
50
  export const META_ROUTES = NAMES.meta;
42
51
  export function routeNames(surface) {
43
52
  return NAMES[surface];
@@ -5,7 +5,7 @@ import { escapeHtml, renderPage } from "../branding.js";
5
5
  import { htmlSecurityHeaders } from "../html-security.js";
6
6
  import { closeConnectorScope } from "../connector-scope.js";
7
7
  import { drainOAuthStartResets } from "../auth/oauth-start-reset.js";
8
- import { finishOAuthConnectStart, consumeOAuthConnectLink, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
8
+ import { finishOAuthConnectStart, claimOAuthConnectStart, oauthConnectUnavailable, verifyOAuthHandoff, } from "../oauth-handoff.js";
9
9
  import { failureRecord, logFailure } from "../operator-record.js";
10
10
  import { runEdge, withDeadlineEffect } from "../runtime/run.js";
11
11
  import { admitsMaintainer, authorizeUiIdentity, mayManageConnector, privateJson, withSessionCookies, } from "./shared.js";
@@ -66,6 +66,9 @@ export function routeConnect(context) {
66
66
  async function connect(context) {
67
67
  const { opts, request, baseUrl, runtimeContext } = context;
68
68
  const refuse = (error, status = 403) => privateJson({ error }, { status });
69
+ const invalidLink = () => refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
70
+ // Spends nothing, so this same link works once the other visit settles.
71
+ const starting = () => refuse("This connection is starting in another visit. Reload this page in a moment. If this stays, start a new connection request.", 409);
69
72
  if (request.method !== "GET")
70
73
  return privateJson({ error: "method not allowed" }, { status: 405, headers: { Allow: "GET" } });
71
74
  const unavailable = oauthConnectUnavailable(opts);
@@ -74,7 +77,7 @@ async function connect(context) {
74
77
  const id = context.path.slice("/connect/".length);
75
78
  const handoff = await verifyOAuthHandoff(opts, baseUrl, id, context.url.searchParams.get("h"));
76
79
  if (!handoff)
77
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
80
+ return invalidLink();
78
81
  const authPage = () => {
79
82
  const target = new URL(`/connectors/${id}`, baseUrl);
80
83
  target.searchParams.set("h", context.url.searchParams.get("h"));
@@ -124,10 +127,11 @@ async function connect(context) {
124
127
  }
125
128
  if (target.origin !== new URL(baseUrl).origin || target.username || target.password)
126
129
  return refuse("Credential management URL is unavailable", 503);
127
- if (!(await consumeOAuthConnectLink(opts, handoff)))
128
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
129
- if (!(await finishOAuthConnectStart(opts, handoff, "started")))
130
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
130
+ const claim = await claimOAuthConnectStart(opts, handoff);
131
+ if (claim === "pending")
132
+ return starting();
133
+ if (claim === "refused" || !(await finishOAuthConnectStart(opts, handoff, "started")))
134
+ return invalidLink();
131
135
  // Credential entry stays in the authenticated operator UI on this origin.
132
136
  return withSessionCookies(new Response(null, {
133
137
  status: 302,
@@ -149,16 +153,20 @@ async function connect(context) {
149
153
  const timeoutError = new ConnectorCallError("timeout", "OAuth authorization start timed out");
150
154
  let claimed = false;
151
155
  try {
152
- if (!(await consumeOAuthConnectLink(opts, handoff)))
153
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
156
+ const claim = await claimOAuthConnectStart(opts, handoff);
157
+ if (claim === "pending")
158
+ return starting();
159
+ if (claim === "refused")
160
+ return invalidLink();
154
161
  claimed = true;
162
+ const force = claim === "reset";
155
163
  const status = await runEdge(withDeadlineEffect((signal) => Effect.tryPromise({
156
164
  try: async () => {
157
165
  ctx = registry.contextFor(id, baseUrl, scope, {
158
166
  signal,
159
167
  ...(context.defer ? { defer: context.defer } : {}),
160
168
  });
161
- const started = await connector.startAuth(ctx, { force: handoff.force });
169
+ const started = await connector.startAuth(ctx, { force });
162
170
  if (signal.aborted)
163
171
  throw signal.reason;
164
172
  if (started.authorizationUrl) {
@@ -181,11 +189,11 @@ async function connect(context) {
181
189
  catch: (error) => error,
182
190
  }), { timeoutMs: START_TIMEOUT_MS, signal: request.signal, timeoutError }));
183
191
  await drainOAuthStartResets(scope);
184
- if (handoff.force || (!status.authorizationReused && status.state !== "ok"))
192
+ if (force || (!status.authorizationReused && status.state !== "ok"))
185
193
  await registry.invalidateStored(id);
186
194
  if (status.state === "ok") {
187
195
  if (!(await finishOAuthConnectStart(opts, handoff, "started")))
188
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
196
+ return invalidLink();
189
197
  return withSessionCookies(new Response("This connector is already connected.", { headers: { "Cache-Control": "no-store" } }), authz.sessionCookies);
190
198
  }
191
199
  if (status.state !== "auth_required" || !status.authorizationUrl) {
@@ -194,7 +202,7 @@ async function connect(context) {
194
202
  // state only, since a plugin's `startAuth` returns whatever it likes.
195
203
  logFailure(opts.config.logger, "OAuth start failed", failureRecord({
196
204
  connector: id,
197
- mode: handoff.force ? "restart" : "continue",
205
+ mode: force ? "restart" : "continue",
198
206
  state: status.state,
199
207
  }));
200
208
  return refuse(status.state === "auth_required"
@@ -205,7 +213,7 @@ async function connect(context) {
205
213
  if (!["https:", "http:"].includes(target.protocol) || target.username || target.password)
206
214
  return refuse("OAuth authorization returned no safe URL", 502);
207
215
  if (!(await finishOAuthConnectStart(opts, handoff, "started")))
208
- return refuse("Invalid or expired connection link. Request a new link from connecta.", 400);
216
+ return invalidLink();
209
217
  return withSessionCookies(new Response(null, { status: 302, headers: { Location: target.href, "Cache-Control": "no-store" } }), authz.sessionCookies);
210
218
  }
211
219
  catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { startTimingTrace } from "../timing-trace.js";
2
- import { routeNames } from "../agent-routes.js";
2
+ import { META_ROUTES, routeNames } from "../agent-routes.js";
3
3
  import { upwardStep } from "../upward.js";
4
4
  import { bindDiscoveryEndpoint } from "../discovery-health.js";
5
5
  import { authCall, authInvoke, authFlow, authMethod } from "../auth/native.js";
@@ -9,15 +9,15 @@ import { observeAgentAdmission } from "../agent-observation.js";
9
9
  import { bindActivityRequest } from "../activity-request.js";
10
10
  import { closeScope } from "../runtime/connector-scope.js";
11
11
  import { executeLimits } from "../config.js";
12
- import { oauthConnectUrl, oauthConnectLink, oauthConnectLinkProgress, closeOAuthConnectLinks, claimAuthRetry, oauthConnectUnavailable, } from "../oauth-handoff.js";
12
+ import { oauthConnectUrl, oauthConnectLink, oauthConnectLinkStarted, claimAuthRetry, oauthConnectUnavailable, } from "../oauth-handoff.js";
13
13
  import { AuthElicitation } from "../auth-elicitation.js";
14
14
  import { classifyInboundRequest, createMcpHandler, isJSONRPCErrorResponse, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
15
15
  import { Duration, Effect, Exit, Option, Result, Scope } from "effect";
16
16
  import { registerExecuteTool } from "../execute.js";
17
17
  import { ExecutorAdmissionError } from "../executor-admission.js";
18
18
  import { registerMetaTools } from "../meta-tools.js";
19
- import { endpointInstructions } from "../skills.js";
20
- import { NATIVE_TOOLS_LIST_TTL_MS, NativeSurface, nativeRequestHint } from "../native-tools.js";
19
+ import { ENDPOINT_EXTENSION, endpointExtension, endpointInstructions } from "../skills.js";
20
+ import { NATIVE_DEGRADED_TOOLS_LIST_TTL_MS, NATIVE_TOOLS_LIST_TTL_MS, NativeSurface, nativeRequestHint, } from "../native-tools.js";
21
21
  import { registerSkills } from "./skills.js";
22
22
  import { failureRecord, logFailure } from "../operator-record.js";
23
23
  import { redactAgentOutput, sentSecretsForRequest } from "../sent-secrets.js";
@@ -322,7 +322,7 @@ function toolkitRetired(logger, sentSecrets) {
322
322
  },
323
323
  });
324
324
  }
325
- function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor) {
325
+ function serveMcp(request, requestSignal, requestStartedAt, requestScope, opts, baseUrl, actor, registry, canManageAuth, principalKey, runtimeContext, readOnly = false, pool, principalActor, agentEndpoint = false) {
326
326
  const sentSecrets = sentSecretsForRequest(requestScope);
327
327
  // Every McpServer the request builds is fresh and closes with its scope.
328
328
  // The modern handler tears its own down after the exchange; the legacy
@@ -330,7 +330,14 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
330
330
  const servers = [];
331
331
  // A native pool lists its downstream tools directly (pools.<name>.surface).
332
332
  const native = pool !== undefined ? opts.pools.get(pool)?.native : undefined;
333
+ // Agent endpoints keep six tools until Eve is tested against the seventh. The
334
+ // eval arms that render other route names measure their own fixed surfaces.
335
+ const readRoute = opts.config.execute.readRoute && !native && !readOnly && !agentEndpoint && opts.routes === META_ROUTES;
333
336
  const createServer = authMethod((hint) => authFlow(function* () {
337
+ // A native pool's text uses the native contract's wording.
338
+ const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
339
+ // Direct-call and handoff text names the program route read follow-ups should take.
340
+ const directRoutes = readRoute ? routeNames("read-route") : routes;
334
341
  const authElicitation = new AuthElicitation({
335
342
  vault: opts.config.vault,
336
343
  publicUrl: opts.config.publicUrl,
@@ -338,41 +345,49 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
338
345
  principal: principalKey,
339
346
  registry,
340
347
  canManage: canManageAuth,
341
- connectLink: authMethod((id, force, after) => authCall(oauthConnectLink, [
348
+ connectLink: authMethod((id, force, flow) => authCall(oauthConnectLink, [
342
349
  opts,
343
350
  opts.config.publicUrl ?? baseUrl,
344
351
  id,
345
352
  principalKey,
346
353
  force,
347
354
  Boolean(opts.config.vault?.seal && opts.config.vault.open),
348
- after,
355
+ flow,
349
356
  ])),
350
- linkProgress: authMethod((id, nonce) => authCall(oauthConnectLinkProgress, [opts, baseUrl, id, nonce])),
351
- closeLinks: authMethod((id, nonces) => authCall(closeOAuthConnectLinks, [opts, baseUrl, id, nonces])),
357
+ linkStarted: authMethod((id, nonce) => authCall(oauthConnectLinkStarted, [opts, baseUrl, id, nonce])),
352
358
  claimRetry: authMethod((id, nonce) => authCall(claimAuthRetry, [opts, baseUrl, id, nonce])),
353
359
  unavailable: oauthConnectUnavailable(opts),
354
360
  credentialUi: Boolean(opts.config.ui && opts.config.vault),
361
+ credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
362
+ routes: directRoutes,
355
363
  requestSignal,
356
364
  requestScope,
357
365
  defer: runtimeContext?.waitUntil?.bind(runtimeContext),
358
366
  });
367
+ // Read as this request's response is built, so a degraded native listing can shorten it.
368
+ const toolsListHint = { ttlMs: native ? NATIVE_TOOLS_LIST_TTL_MS : 3_600_000, cacheScope: "private" };
369
+ const connectors = registry.listConnectors();
370
+ const effectiveReadOnly = readOnly || (connectors.length > 0 && connectors.every((connector) => connector.readOnly === true));
359
371
  const server = new McpServer(opts.config.serverInfo, {
360
372
  // A request-local server cannot publish catalog changes. Set this before
361
373
  // tool registration, whose SDK default otherwise advertises listChanged.
362
- capabilities: { tools: { listChanged: false }, extensions: { "io.modelcontextprotocol/skills": {} } },
363
- // Instructions reflect the admitted pool and effective write policy. Every cache hint below is private, so a client keeps
364
- // what it learned per endpoint URL and never shares it across them.
365
- instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined, native, readOnly ||
366
- (registry.listConnectors().length > 0 &&
367
- registry.listConnectors().every((connector) => connector.readOnly === true))),
374
+ capabilities: {
375
+ tools: { listChanged: false },
376
+ extensions: {
377
+ "io.modelcontextprotocol/skills": {},
378
+ [ENDPOINT_EXTENSION]: endpointExtension(connectors, native !== undefined, native?.executeCode ?? true, effectiveReadOnly),
379
+ },
380
+ },
381
+ // Instructions reflect the admitted pool, its connector roster and the effective write
382
+ // policy. `registry` is this caller's admitted view, the one execute_code's inventory
383
+ // reads, so a person at plain /mcp never sees an id outside their pool. Every cache hint
384
+ // below is private, so a client keeps what it learned for this caller and endpoint URL.
385
+ instructions: endpointInstructions(pool !== undefined ? opts.pools.get(pool)?.description : undefined, native, effectiveReadOnly, connectors.map((connector) => connector.id), readRoute),
368
386
  inputRequired: { legacyShim: false },
369
387
  requestState: { verify: (state, context) => authElicitation.verify(state, context) },
370
388
  cacheHints: {
371
389
  "server/discover": { ttlMs: 3_600_000, cacheScope: "private" },
372
- "tools/list": {
373
- ttlMs: native ? NATIVE_TOOLS_LIST_TTL_MS : 3_600_000,
374
- cacheScope: "private",
375
- },
390
+ "tools/list": toolsListHint,
376
391
  },
377
392
  });
378
393
  servers.push(server);
@@ -400,12 +415,15 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
400
415
  probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
401
416
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
402
417
  });
403
- const diagnostics = native ? { ...native, catalogErrors: [] } : undefined;
404
- // A native pool's text uses the native contract's wording.
405
- const routes = native ? routeNames(native.executeCode ? "native-code" : "native") : opts.routes;
418
+ const diagnostics = native
419
+ ? { ...native, catalogErrors: [], staleCatalogs: [] }
420
+ : undefined;
421
+ // The program tool this request lists and its reads may offer: the read route, where listed.
422
+ const programTool = !native || native.executeCode ? directRoutes.program : undefined;
406
423
  const nativeRoute = registerMetaTools(server, registry, {
407
424
  native: diagnostics,
408
- routes,
425
+ programTool,
426
+ routes: directRoutes,
409
427
  authElicitation,
410
428
  client,
411
429
  baseUrl,
@@ -417,19 +435,23 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
417
435
  credentialHandoffUrl: opts.config.ui?.credentialHandoffUrl(baseUrl),
418
436
  ...(activity ? { activity } : {}),
419
437
  defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
438
+ responseCeilingMs: opts.config.execute.watchdogMs,
439
+ requestStartedAt,
420
440
  maxProgramResultChars: opts.config.execute.maxResultChars,
441
+ maxProgramHostCalls: opts.config.execute.maxHostCalls,
421
442
  probeTimeoutMs: opts.config.discovery.probeTimeoutMs,
422
443
  discoveryConcurrency: opts.config.discovery.concurrency,
423
444
  requestSignal,
424
445
  ...(runtimeContext?.waitUntil ? { defer: runtimeContext.waitUntil.bind(runtimeContext) } : {}),
425
446
  });
426
- if (!native || native.executeCode) {
447
+ if (programTool) {
427
448
  registerExecuteTool(server, registry, {
428
449
  authElicitation,
429
450
  client,
430
451
  baseUrl,
431
452
  requestScope,
432
453
  executor: opts.executor,
454
+ requestStartedAt,
433
455
  defaultToolTimeoutMs: opts.config.calls.defaultTimeoutMs ?? opts.config.execute.hostCallTimeoutMs,
434
456
  logger: opts.config.logger,
435
457
  ...(activity ? { activity } : {}),
@@ -439,6 +461,7 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
439
461
  readOnly,
440
462
  native: native !== undefined,
441
463
  routes,
464
+ readRoute,
442
465
  });
443
466
  }
444
467
  if (native && nativeRoute && hint) {
@@ -448,6 +471,10 @@ function serveMcp(request, requestSignal, requestScope, opts, baseUrl, actor, re
448
471
  route: nativeRoute,
449
472
  bind: (context) => authElicitation.bind(context),
450
473
  catalogErrors: diagnostics.catalogErrors,
474
+ staleCatalogs: diagnostics.staleCatalogs,
475
+ onDegradedListing: () => {
476
+ toolsListHint.ttlMs = NATIVE_DEGRADED_TOOLS_LIST_TTL_MS;
477
+ },
451
478
  logger: opts.config.logger,
452
479
  requestScope,
453
480
  requestSignal,
@@ -665,6 +692,8 @@ export function createMcpRoute(opts) {
665
692
  // The Node adapter has already buffered up to its own pre-auth cap
666
693
  // (`listen`'s `maxBodyBytes`) before a request reaches core.
667
694
  const startedAt = Date.now();
695
+ // The response ceiling counts from here; a future stamp could only extend it.
696
+ const arrivedAt = Math.min(context.runtimeContext?.requestArrivedAt ?? startedAt, startedAt);
668
697
  const maxDurationMs = opts.requestAdmission.maxDurationMs;
669
698
  const listenHeader = request.method === "POST" && request.headers.get("Mcp-Method") === "subscriptions/listen";
670
699
  const refuseAdmission = (error) => {
@@ -942,7 +971,7 @@ export function createMcpRoute(opts) {
942
971
  if (new URL(request.url).searchParams.has("toolkit")) {
943
972
  return cors(toolkitRetired(opts.config.logger, sentSecrets));
944
973
  }
945
- return cors(yield* serveMcp(localRequest, localAbort.signal, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
974
+ return cors(yield* serveMcp(localRequest, localAbort.signal, arrivedAt, requestScope, opts, baseUrl, authz.actor, scopedRegistry, (id) => {
946
975
  const connector = scopedRegistry.getConnector(id);
947
976
  return Boolean(connector && mayManageConnector(opts, authz, connector));
948
977
  }, authz.principalKey, runtimeContext, readOnly, scope.pool, authz.identity.principal
@@ -951,7 +980,7 @@ export function createMcpRoute(opts) {
951
980
  id: authz.identity.principal.id,
952
981
  namespace: authz.identity.principal.namespace,
953
982
  }
954
- : undefined));
983
+ : undefined, agentEndpoint));
955
984
  });
956
985
  if (remainingMs === undefined)
957
986
  return yield* handled;
@@ -34,6 +34,12 @@ export interface ServerOptions {
34
34
  }
35
35
  export interface RuntimeExecutionContext extends InboundAuthRuntimeContext {
36
36
  waitUntil(promise: Promise<unknown>): void;
37
+ /**
38
+ * When the Node adapter received this request's headers, before it buffered
39
+ * the body. It comes from the host, never from anything the request carries.
40
+ * Workers read the body lazily, so the route's own clock already counts upload.
41
+ */
42
+ readonly requestArrivedAt?: number;
37
43
  }
38
44
  export interface RouteContext {
39
45
  request: Request;
@@ -359,17 +359,22 @@ export function wrapViewCredentials(view) {
359
359
  return;
360
360
  entry.credentialWrapped = true;
361
361
  const access = view.credential;
362
+ // A read outlives a caller that stopped waiting and registers when it lands, so the
363
+ // request counts it until then: a sealing run must not publish ahead of that.
364
+ const read = async (value, values) => {
365
+ const done = requestOf(entry.request).credentialReads.begin();
366
+ try {
367
+ const result = await value();
368
+ registerCredentialValues(viewSecrets(view), values(result));
369
+ return result;
370
+ }
371
+ finally {
372
+ done();
373
+ }
374
+ };
362
375
  view.credential = {
363
- get: (field) => settleOnClose(view, async () => {
364
- const value = await access.get(field);
365
- registerCredentialValues(viewSecrets(view), value === null ? null : { [field ?? "value"]: value });
366
- return value;
367
- }, true),
368
- getAll: () => settleOnClose(view, async () => {
369
- const values = await access.getAll();
370
- registerCredentialValues(viewSecrets(view), values);
371
- return values;
372
- }, true),
376
+ get: (field) => settleOnClose(view, () => read(() => access.get(field), (value) => (value === null ? null : { [field ?? "value"]: value })), true),
377
+ getAll: () => settleOnClose(view, () => read(() => access.getAll(), (values) => values), true),
373
378
  };
374
379
  }
375
380
  /** Credentials for request-owned transports use the operation, never a closed call. */
@@ -1,9 +1,42 @@
1
1
  import { traceFor } from "../timing-trace.js";
2
2
  // One owner per existing upstream request identity. Facts survive revocation;
3
3
  // retaining them never reconstitutes a call capability or a live operation.
4
- import { Context, Exit, Scope } from "effect";
4
+ import { Context, Effect, Exit, Scope } from "effect";
5
5
  import { RequestReads } from "./request-reads.js";
6
6
  import { detach } from "./run.js";
7
+ /** Work that can still add to a secret set. A sealing run waits until none is left. */
8
+ export class PendingWork {
9
+ active = 0;
10
+ waiting = new Set();
11
+ get pending() {
12
+ return this.active > 0;
13
+ }
14
+ /** Returns the function that ends this piece of work; calling it twice counts once. */
15
+ begin() {
16
+ this.active++;
17
+ let done = false;
18
+ return () => {
19
+ if (done)
20
+ return;
21
+ done = true;
22
+ if (--this.active > 0)
23
+ return;
24
+ for (const wake of this.waiting)
25
+ wake();
26
+ this.waiting.clear();
27
+ };
28
+ }
29
+ /** Succeeds when nothing is pending. Interrupting it drops the wait. */
30
+ idle = Effect.callback((resume) => {
31
+ if (this.active === 0)
32
+ return resume(Effect.void);
33
+ const wake = () => resume(Effect.void);
34
+ this.waiting.add(wake);
35
+ return Effect.sync(() => {
36
+ this.waiting.delete(wake);
37
+ });
38
+ });
39
+ }
7
40
  export class RequestOwner {
8
41
  identity;
9
42
  open = true;
@@ -14,6 +47,8 @@ export class RequestOwner {
14
47
  calls = new Set();
15
48
  signals = new WeakMap();
16
49
  scope = Scope.makeUnsafe();
50
+ /** Credential reads whose values have yet to join this request's secret set. */
51
+ credentialReads = new PendingWork();
17
52
  // Initialize secrets only when data is sent. Metadata-only UI imports must
18
53
  // not retain the payload matcher merely to revoke a request.
19
54
  retained = {
@@ -128,7 +128,10 @@ export function withDeadlineEffect(operation, options) {
128
128
  fromSignal(controller.signal),
129
129
  ];
130
130
  if (options.timeoutMs !== undefined) {
131
- contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
131
+ contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.suspend(() => {
132
+ const more = options.extendMs?.() ?? 0;
133
+ return more > 0 ? Effect.sleep(Duration.millis(more)) : Effect.void;
134
+ })), Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
132
135
  }
133
136
  return Effect.raceAllFirst(contenders).pipe(Effect.ensuring(Effect.sync(() => {
134
137
  controller.abort();