@zackbart/connecta 0.24.3 → 0.25.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/AGENTS.md +58 -30
- package/CHANGELOG.md +317 -1
- package/README.md +5 -6
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/branding.d.ts +31 -2
- package/dist/branding.js +116 -8
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +3 -3
- package/dist/connectors/api.js +10 -2
- package/dist/connectors/guarded-fetch.js +34 -4
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +325 -245
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +11 -3
- package/dist/errors.js +2 -1
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.js +63 -21
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +304 -114
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +109 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/cloudflare.d.ts +14 -2
- package/dist/providers/cloudflare.js +107 -16
- package/dist/providers/linear.d.ts +26 -4
- package/dist/providers/linear.js +19 -4
- package/dist/providers/mixpanel.d.ts +16 -3
- package/dist/providers/mixpanel.js +13 -2
- package/dist/providers/notion.d.ts +8 -1
- package/dist/providers/notion.js +85 -12
- package/dist/providers/revenuecat.d.ts +30 -4
- package/dist/providers/revenuecat.js +70 -5
- package/dist/providers/stripe.d.ts +10 -3
- package/dist/providers/stripe.js +30 -4
- package/dist/providers/vercel.js +12 -2
- package/dist/registry.d.ts +13 -5
- package/dist/registry.js +317 -258
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +64 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +68 -176
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +368 -182
- package/documentation/auth.md +277 -176
- package/documentation/code-mode.md +479 -329
- package/documentation/meta-tools.md +494 -417
- package/ethos.md +44 -47
- package/examples/worker/AGENTS.md +2 -1
- package/examples/worker/README.md +62 -14
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +18 -21
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -4
- package/templates/node/.env.example +3 -3
- package/templates/node/AGENTS.md +5 -4
- package/templates/node/README.md +2 -1
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +23 -22
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -92
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -151
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
- package/documentation/call-admission.md +0 -158
- package/documentation/cloudflare.md +0 -471
- package/documentation/connector-guides.md +0 -176
- package/documentation/connectors.md +0 -431
- package/documentation/linear.md +0 -193
- package/documentation/mixpanel.md +0 -160
- package/documentation/notion.md +0 -308
- package/documentation/operations.md +0 -359
- package/documentation/operator-ui.md +0 -135
- package/documentation/optional-modules-upgrade.md +0 -243
- package/documentation/provider-conventions.md +0 -729
- package/documentation/request-admission.md +0 -204
- package/documentation/revenuecat.md +0 -305
- package/documentation/storage-and-credentials.md +0 -254
- package/documentation/stripe.md +0 -262
- package/documentation/upgrading.md +0 -768
- package/documentation/vercel.md +0 -241
package/dist/meta-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { advertisedSchema } from "./advertised-schema.js";
|
|
2
3
|
import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, groupedSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
|
|
3
4
|
import { resolveDiscoveryConcurrency } from "./concurrency.js";
|
|
4
5
|
import { boundedEchoText, msg } from "./errors.js";
|
|
@@ -7,6 +8,7 @@ import { InvocationService, } from "./invocation.js";
|
|
|
7
8
|
import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
|
|
8
9
|
import { hasConnectorGuides, listSkills, resolveSkill, } from "./skills.js";
|
|
9
10
|
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, } from "./timeout.js";
|
|
11
|
+
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
10
12
|
export { MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, };
|
|
11
13
|
const RESULT_TTL_SECONDS = 900;
|
|
12
14
|
const enc = new TextEncoder();
|
|
@@ -99,19 +101,103 @@ export function alignEndToCharBoundary(bytes, offset, end, total) {
|
|
|
99
101
|
}
|
|
100
102
|
return e;
|
|
101
103
|
}
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
+
/** Prefix of the chunked paging envelope; `v1`'s single inline key still reads. */
|
|
105
|
+
const RESULT_ENVELOPE_V2 = "connecta-result-v2:";
|
|
106
|
+
/**
|
|
107
|
+
* The current envelope: v2's header plus the inline cap of the call that
|
|
108
|
+
* stashed it, `<total>:<chunk>:<cap>:`, so get_result can clamp a page to the
|
|
109
|
+
* connector's own cap without the stash carrying connector identity.
|
|
110
|
+
*/
|
|
111
|
+
const RESULT_ENVELOPE_V3 = "connecta-result-v3:";
|
|
112
|
+
const RESULT_ENVELOPE_V3_HEADER = new RegExp(`^${RESULT_ENVELOPE_V3}(\\d+):(\\d+):(\\d+):`);
|
|
113
|
+
/** `<total bytes>:<bytes per chunk>:` follow the prefix, then chunk 0's base64. */
|
|
114
|
+
const RESULT_ENVELOPE_V2_HEADER = new RegExp(`^${RESULT_ENVELOPE_V2}(\\d+):(\\d+):`);
|
|
115
|
+
/**
|
|
116
|
+
* Smallest chunk of result text stored under one key. A multiple of three so
|
|
117
|
+
* every chunk's base64 stands alone and a byte offset inside it lands on a
|
|
118
|
+
* whole quad, and about twice the 24,000-byte default page so a default page
|
|
119
|
+
* reads one or two chunks rather than dozens.
|
|
120
|
+
*/
|
|
121
|
+
const RESULT_CHUNK_BYTES = 49_152;
|
|
122
|
+
/**
|
|
123
|
+
* Keys one stashed result may occupy. Chunking trades write count for read
|
|
124
|
+
* count, and both are real: every chunk is a storage write at stash time, and
|
|
125
|
+
* `fileStorage` rewrites its whole file per write. Above roughly 1.5 MB the
|
|
126
|
+
* chunks widen instead of multiplying, so a result costs a bounded number of
|
|
127
|
+
* writes and a page still reads a small fraction of it.
|
|
128
|
+
*/
|
|
129
|
+
const RESULT_MAX_CHUNKS = 32;
|
|
130
|
+
/** Chunk width for a result of `totalBytes`, always a multiple of three. */
|
|
131
|
+
function resultChunkBytes(totalBytes) {
|
|
132
|
+
return Math.max(RESULT_CHUNK_BYTES, Math.ceil(totalBytes / RESULT_MAX_CHUNKS / 3) * 3);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Base64 of `bytes`, in three-byte-aligned batches so the argument list of one
|
|
136
|
+
* spread never grows with the result. Alignment matters: an unaligned batch
|
|
137
|
+
* would pad mid-stream and the concatenation would no longer decode.
|
|
138
|
+
*/
|
|
139
|
+
function base64Of(bytes) {
|
|
140
|
+
let out = "";
|
|
141
|
+
for (let offset = 0; offset < bytes.length; offset += 12_288) {
|
|
142
|
+
out += btoa(String.fromCharCode(...bytes.subarray(offset, offset + 12_288)));
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Opens a write's notice, ahead of any paging instruction. */
|
|
147
|
+
const WRITE_ALREADY_RAN = "This write already ran: do not call it again to see its result.";
|
|
148
|
+
/**
|
|
149
|
+
* Offers a read the route meta-tools.md prefers for finding something in it:
|
|
150
|
+
* repeat the call inside a program and reduce it there. Paging comes second,
|
|
151
|
+
* for reading the whole thing. Led by paging alone, the eval's weakest model
|
|
152
|
+
* read a 185 KB log in eight pages, skipped the range holding the answer, and
|
|
153
|
+
* reported the decoy near the top instead. A write never gets this: repeating
|
|
154
|
+
* it is the one thing its notice forbids.
|
|
155
|
+
*/
|
|
156
|
+
const READ_REDUCE_FIRST = "To find something specific, repeat this read inside execute_code with connecta.call and filter or search the result there, returning only what matters";
|
|
157
|
+
function pagingHint(results, totalBytes, preview) {
|
|
158
|
+
if (!results.write) {
|
|
159
|
+
const [shown, full] = preview.kind === "prefix"
|
|
160
|
+
? [`Bytes 0-${preview.bytes} of ${totalBytes} follow.`, "to read it in full, page the rest"]
|
|
161
|
+
: preview.kind === "text-of-envelope"
|
|
162
|
+
? ["Its text follows.", `to read the full ${totalBytes}-byte content array as JSON, page it`]
|
|
163
|
+
: [`The result is ${totalBytes} bytes.`, "to read it in full, page it"];
|
|
164
|
+
return `${shown} ${READ_REDUCE_FIRST}; ${full} with get_result using nextAction.`;
|
|
165
|
+
}
|
|
166
|
+
const body = preview.kind === "prefix"
|
|
167
|
+
? `Bytes 0-${preview.bytes} of ${totalBytes} follow; page the rest with get_result using nextAction.`
|
|
168
|
+
: preview.kind === "text-of-envelope"
|
|
169
|
+
? `Its text follows; page the full ${totalBytes}-byte content array as JSON with get_result using nextAction.`
|
|
170
|
+
: `Page all ${totalBytes} bytes with get_result using nextAction.`;
|
|
171
|
+
return `${WRITE_ALREADY_RAN} ${body}`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Stash a completed result and describe it with a notice that leads whatever
|
|
175
|
+
* the caller returns, or a notice without a paging route when stashing fails.
|
|
176
|
+
*
|
|
177
|
+
* The notice goes first because clients cut oversized results from the end:
|
|
178
|
+
* Claude Code keeps the first 2,000 characters of a result it spills to disk,
|
|
179
|
+
* and a notice at the tail never reached the agent (see
|
|
180
|
+
* documentation/meta-tools.md#result-representation). It is one compact JSON
|
|
181
|
+
* line with no raw newline, so the preview starts after the first `\n`.
|
|
182
|
+
*/
|
|
183
|
+
async function stashResult(bytes, results, preview) {
|
|
104
184
|
const totalBytes = bytes.length;
|
|
105
185
|
const id = crypto.randomUUID();
|
|
106
186
|
try {
|
|
107
|
-
// Base64 permits byte-range decoding
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
187
|
+
// Base64 permits byte-range decoding, and splitting the envelope across
|
|
188
|
+
// keys keeps a page's storage read proportional to the page instead of to
|
|
189
|
+
// the whole result (issue #540). Chunk 0 carries the header; the get_result
|
|
190
|
+
// reader below maps a byte offset back to chunk index and base64 quad.
|
|
191
|
+
const chunkBytes = resultChunkBytes(totalBytes);
|
|
192
|
+
const chunks = [`${RESULT_ENVELOPE_V3}${totalBytes}:${chunkBytes}:${results.cap}:`];
|
|
193
|
+
for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
|
|
194
|
+
const chunk = base64Of(bytes.subarray(offset, offset + chunkBytes));
|
|
195
|
+
if (offset === 0)
|
|
196
|
+
chunks[0] += chunk;
|
|
197
|
+
else
|
|
198
|
+
chunks.push(chunk);
|
|
112
199
|
}
|
|
113
|
-
|
|
114
|
-
if (!await results.set(`result:${id}`, stored, RESULT_TTL_SECONDS)) {
|
|
200
|
+
if (!await results.set(`result:${id}`, chunks, RESULT_TTL_SECONDS)) {
|
|
115
201
|
throw new Error("Result stash capacity exhausted");
|
|
116
202
|
}
|
|
117
203
|
}
|
|
@@ -127,25 +213,37 @@ async function stashResult(bytes, results) {
|
|
|
127
213
|
return {
|
|
128
214
|
truncated: true,
|
|
129
215
|
totalBytes,
|
|
130
|
-
hint:
|
|
216
|
+
hint: results.write
|
|
217
|
+
? `Paging is unavailable. ${WRITE_ALREADY_RAN}`
|
|
218
|
+
: "Paging is unavailable. Use execute_code to reduce read-only results before returning them.",
|
|
131
219
|
};
|
|
132
220
|
}
|
|
133
221
|
return {
|
|
134
222
|
truncated: true,
|
|
135
223
|
resultId: id,
|
|
136
224
|
totalBytes,
|
|
137
|
-
hint:
|
|
225
|
+
hint: pagingHint(results, totalBytes, preview),
|
|
138
226
|
nextAction: {
|
|
139
227
|
tool: "get_result",
|
|
140
|
-
arguments: { id, offset: 0 },
|
|
228
|
+
arguments: { id, offset: preview.kind === "prefix" ? preview.bytes : 0 },
|
|
141
229
|
},
|
|
142
230
|
};
|
|
143
231
|
}
|
|
232
|
+
/** The first `cap` bytes of `bytes`, ending on a character boundary. */
|
|
233
|
+
function headOf(bytes, cap) {
|
|
234
|
+
return bytes.subarray(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length));
|
|
235
|
+
}
|
|
236
|
+
/** One text block: the notice line, then the preview. */
|
|
237
|
+
function noticeFirst(notice, preview) {
|
|
238
|
+
return { content: [{ type: "text", text: `${JSON.stringify(notice)}\n${preview}` }] };
|
|
239
|
+
}
|
|
144
240
|
/**
|
|
145
241
|
* Return `text` as a single content block; if it exceeds `cap` bytes, stash the
|
|
146
|
-
* full text and return
|
|
147
|
-
*
|
|
148
|
-
*
|
|
242
|
+
* full text and return a JSON truncation notice pointing at get_result,
|
|
243
|
+
* followed by the first `cap` bytes. The preview is a byte prefix of what
|
|
244
|
+
* pages, so the notice's next action continues where it stops. `bytes` is
|
|
245
|
+
* `text` already encoded, so a caller that had to measure it to make this
|
|
246
|
+
* decision doesn't encode it twice.
|
|
149
247
|
*/
|
|
150
248
|
async function guardEncoded(text, bytes, results, cap) {
|
|
151
249
|
if (bytes.length <= cap) {
|
|
@@ -154,14 +252,12 @@ async function guardEncoded(text, bytes, results, cap) {
|
|
|
154
252
|
truncated: false,
|
|
155
253
|
};
|
|
156
254
|
}
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
truncated: true,
|
|
164
|
-
};
|
|
255
|
+
const head = headOf(bytes, cap);
|
|
256
|
+
const notice = await stashResult(bytes, results, {
|
|
257
|
+
kind: "prefix",
|
|
258
|
+
bytes: head.length,
|
|
259
|
+
});
|
|
260
|
+
return { result: noticeFirst(notice, dec.decode(head)), truncated: true };
|
|
165
261
|
}
|
|
166
262
|
/** {@link guardEncoded} over a string that has not been measured yet. */
|
|
167
263
|
async function guardText(text, results, cap) {
|
|
@@ -179,34 +275,62 @@ async function guardValue(value, results, cap) {
|
|
|
179
275
|
const bytes = enc.encode(text);
|
|
180
276
|
if (bytes.length <= cap)
|
|
181
277
|
return { result: value, truncated: false };
|
|
182
|
-
const notice = await stashResult(bytes, results);
|
|
278
|
+
const notice = await stashResult(bytes, results, { kind: "none" });
|
|
183
279
|
return {
|
|
184
280
|
result: notice.resultId ? notice : {
|
|
185
281
|
...notice,
|
|
186
|
-
preview:
|
|
282
|
+
preview: escapedHeadOf(bytes, cap),
|
|
187
283
|
},
|
|
188
284
|
truncated: true,
|
|
189
285
|
};
|
|
190
286
|
}
|
|
191
287
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
288
|
+
* The longest head of `bytes` whose JSON string form fits `cap` bytes. Value
|
|
289
|
+
* mode carries an unpageable preview inside its JSON envelope, where escaping
|
|
290
|
+
* could double a quote-heavy head cut at `cap` bytes; a response that only
|
|
291
|
+
* exists because paging failed must not be the one that breaks the cap.
|
|
292
|
+
*/
|
|
293
|
+
function escapedHeadOf(bytes, cap) {
|
|
294
|
+
let limit = cap;
|
|
295
|
+
for (;;) {
|
|
296
|
+
const head = limit === 0 ? "" : dec.decode(headOf(bytes, limit));
|
|
297
|
+
const escaped = enc.encode(JSON.stringify(head)).length;
|
|
298
|
+
if (escaped <= cap || limit === 0)
|
|
299
|
+
return head;
|
|
300
|
+
limit = Math.max(0, Math.min(limit - 1, Math.floor((limit * cap) / escaped)));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Bound a downstream MCP `content` array by `cap`.
|
|
305
|
+
*
|
|
306
|
+
* A lone text block is measured, stashed, previewed, and paged as its text:
|
|
307
|
+
* its envelope adds nothing but a wrapper and a second layer of JSON escaping,
|
|
308
|
+
* which made a JSON payload's preview unreadable and inflated every page.
|
|
195
309
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
310
|
+
* Anything else is measured as the serialized envelope — the same string that
|
|
311
|
+
* gets stashed and paged, and the one that counts every block rather than only
|
|
312
|
+
* the text ones. Both halves matter (issue #43). Measuring only text blocks
|
|
313
|
+
* meant an oversized all-image result scored zero bytes and was returned inline
|
|
314
|
+
* unbounded, with no `resultId` to page from; and measuring one string while
|
|
315
|
+
* truncating another left `totalBytes` describing something the cap was never
|
|
200
316
|
* compared against.
|
|
201
317
|
*
|
|
202
|
-
* Over the cap, what a client gets depends on whether a
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
* envelope is stashed and pages through `get_result`.
|
|
318
|
+
* Over the cap, what a client gets depends on whether a preview is usable.
|
|
319
|
+
* Several text blocks preview as their text joined by newlines, while
|
|
320
|
+
* get_result pages the envelope from offset 0, where the block boundaries
|
|
321
|
+
* live. An envelope carrying non-text blocks is replaced by the notice alone:
|
|
322
|
+
* the head of a half-written base64 image is of no use to anyone. Either way
|
|
323
|
+
* the full envelope is stashed and pages through `get_result`.
|
|
208
324
|
*/
|
|
209
325
|
async function guardContent(content, results, cap) {
|
|
326
|
+
const only = content.length === 1 ? content[0] : undefined;
|
|
327
|
+
if (only?.type === "text" && typeof only.text === "string") {
|
|
328
|
+
const bytes = enc.encode(only.text);
|
|
329
|
+
// Under the cap the block passes through untouched, annotations included.
|
|
330
|
+
if (bytes.length <= cap)
|
|
331
|
+
return { result: { content }, truncated: false };
|
|
332
|
+
return guardEncoded(only.text, bytes, results, cap);
|
|
333
|
+
}
|
|
210
334
|
let text;
|
|
211
335
|
try {
|
|
212
336
|
text = JSON.stringify(content);
|
|
@@ -225,9 +349,14 @@ async function guardContent(content, results, cap) {
|
|
|
225
349
|
return { result: { content }, truncated: false };
|
|
226
350
|
}
|
|
227
351
|
if (content.every((b) => b.type === "text")) {
|
|
228
|
-
|
|
352
|
+
const joined = enc.encode(content.map((b) => b.text).join("\n"));
|
|
353
|
+
const notice = await stashResult(bytes, results, { kind: "text-of-envelope" });
|
|
354
|
+
return {
|
|
355
|
+
result: noticeFirst(notice, dec.decode(headOf(joined, cap))),
|
|
356
|
+
truncated: true,
|
|
357
|
+
};
|
|
229
358
|
}
|
|
230
|
-
const notice = await stashResult(bytes, results);
|
|
359
|
+
const notice = await stashResult(bytes, results, { kind: "none" });
|
|
231
360
|
return {
|
|
232
361
|
result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
|
|
233
362
|
truncated: true,
|
|
@@ -270,6 +399,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
270
399
|
probeTimeoutMs,
|
|
271
400
|
concurrency: discoveryConcurrency,
|
|
272
401
|
defer: opts.defer,
|
|
402
|
+
requestSignal: opts.requestSignal,
|
|
273
403
|
// searchRoute keeps its top-level default. In-program callers use a
|
|
274
404
|
// separate CatalogService configured for connecta.search.
|
|
275
405
|
});
|
|
@@ -288,13 +418,6 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
288
418
|
: {}),
|
|
289
419
|
unwrapResult: call.resultMode === "value",
|
|
290
420
|
processResult: async (result, resolved) => {
|
|
291
|
-
const results = {
|
|
292
|
-
set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
|
|
293
|
-
warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
|
|
294
|
-
connector: resolved.connector.id,
|
|
295
|
-
tool: resolved.toolName,
|
|
296
|
-
}),
|
|
297
|
-
};
|
|
298
421
|
// Result-size cap for THIS call: the connector's own override wins,
|
|
299
422
|
// then the deployment-wide value, then the built-in default (already
|
|
300
423
|
// folded into `globalCap`). Resolved per call so one request can
|
|
@@ -302,6 +425,15 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
302
425
|
// override the registry already warned about at startup is dropped
|
|
303
426
|
// here, so the connector simply inherits `globalCap`.
|
|
304
427
|
const cap = resolveMaxResultBytes(resolved.connector.maxResultBytes, globalCap);
|
|
428
|
+
const results = {
|
|
429
|
+
write: !isExplicitlyReadOnly(resolved.definition),
|
|
430
|
+
cap,
|
|
431
|
+
set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
|
|
432
|
+
warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
|
|
433
|
+
connector: resolved.connector.id,
|
|
434
|
+
tool: resolved.toolName,
|
|
435
|
+
}),
|
|
436
|
+
};
|
|
305
437
|
const processed = (toolResult, truncated, value) => ({
|
|
306
438
|
toolResult,
|
|
307
439
|
...value,
|
|
@@ -449,39 +581,83 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
449
581
|
`>= ${MIN_RESULT_OFFSET}. Omit it to start at the beginning.`);
|
|
450
582
|
}
|
|
451
583
|
const results = registry.resultsStorage();
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
584
|
+
const unavailableResult = () => ({
|
|
585
|
+
...jsonResult({
|
|
586
|
+
error: {
|
|
587
|
+
code: "unavailable",
|
|
588
|
+
message: "Result paging storage is unavailable.",
|
|
589
|
+
retryable: true,
|
|
590
|
+
},
|
|
591
|
+
}),
|
|
592
|
+
isError: true,
|
|
593
|
+
});
|
|
594
|
+
// `false` is a storage failure — retryable, and distinct from an id that
|
|
595
|
+
// is simply gone. Every key a page touches answers the same way, so a
|
|
596
|
+
// backend that dies halfway through a multi-chunk page says so.
|
|
597
|
+
const read = async (key) => {
|
|
598
|
+
try {
|
|
599
|
+
return await results.get(key) ?? null;
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
const stored = await read(`result:${args.id}`);
|
|
606
|
+
if (stored === false)
|
|
607
|
+
return unavailableResult();
|
|
608
|
+
if (stored === null) {
|
|
469
609
|
return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
|
|
470
610
|
}
|
|
471
611
|
const requestedOffset = args.offset ?? 0;
|
|
472
|
-
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
612
|
+
// Read and decode only the chunks this page covers, plus a few bytes of
|
|
613
|
+
// UTF-8 boundary lookaround. Pre-upgrade entries stay readable for their
|
|
614
|
+
// short TTL: v2 is this format without the recorded cap, v1 inlined the
|
|
615
|
+
// whole envelope under one key, which is v2 with a single chunk as wide
|
|
616
|
+
// as the result, and raw text before that still pays one full encode
|
|
617
|
+
// per page.
|
|
618
|
+
const current = RESULT_ENVELOPE_V3_HEADER.exec(stored.slice(0, 96));
|
|
619
|
+
const chunked = current ?? RESULT_ENVELOPE_V2_HEADER.exec(stored.slice(0, 80));
|
|
620
|
+
const inline = chunked ? null : /^connecta-result-v1:(\d+):/.exec(stored.slice(0, 64));
|
|
621
|
+
const header = chunked ?? inline;
|
|
622
|
+
// A page never exceeds the inline cap of the call that stashed it — the
|
|
623
|
+
// connector's override when it had one — whatever maxBytes asks for.
|
|
624
|
+
// Clients cut an oversized page exactly as they cut the original result,
|
|
625
|
+
// so honouring a larger request would hand the agent an error in place
|
|
626
|
+
// of its data. Entries from before the cap was recorded use the
|
|
627
|
+
// deployment-wide one.
|
|
628
|
+
const cap = current ? Number(current[3]) : globalCap;
|
|
629
|
+
const maxBytes = Math.min(args.maxBytes ?? cap, cap);
|
|
476
630
|
let bytes;
|
|
477
631
|
let total;
|
|
478
632
|
let start = 0;
|
|
479
633
|
if (header) {
|
|
480
634
|
total = Number(header[1]);
|
|
635
|
+
const chunkBytes = chunked ? Number(chunked[2]) : Math.max(total, 1);
|
|
481
636
|
start = Math.floor(Math.max(0, Math.min(requestedOffset, total) - 3) / 3) * 3;
|
|
482
637
|
const end = Math.min(total, requestedOffset + maxBytes + 4);
|
|
483
|
-
|
|
484
|
-
|
|
638
|
+
bytes = new Uint8Array(Math.max(0, end - start));
|
|
639
|
+
const lastChunk = Math.floor(Math.max(end - 1, start) / chunkBytes);
|
|
640
|
+
for (let index = Math.floor(start / chunkBytes); index <= lastChunk; index++) {
|
|
641
|
+
const encoded = index === 0
|
|
642
|
+
? stored.slice(header[0].length)
|
|
643
|
+
: await read(`result:${args.id}#${index}`);
|
|
644
|
+
if (encoded === false)
|
|
645
|
+
return unavailableResult();
|
|
646
|
+
if (encoded === null) {
|
|
647
|
+
// A chunk expired or was evicted under its own header; the id can no
|
|
648
|
+
// longer serve this range, and inventing U+0000 filler would be worse.
|
|
649
|
+
return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
|
|
650
|
+
}
|
|
651
|
+
const chunkStart = index * chunkBytes;
|
|
652
|
+
// Both bounds are chunk-local. `from` inherits `start`'s three-byte
|
|
653
|
+
// alignment because every chunk boundary is a multiple of three.
|
|
654
|
+
const from = Math.max(start, chunkStart) - chunkStart;
|
|
655
|
+
const to = Math.min(end, chunkStart + chunkBytes, total) - chunkStart;
|
|
656
|
+
const binary = atob(encoded.slice(from / 3 * 4, Math.ceil(to / 3) * 4));
|
|
657
|
+
for (let at = from; at < to; at++) {
|
|
658
|
+
bytes[chunkStart + at - start] = binary.charCodeAt(at - from);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
485
661
|
}
|
|
486
662
|
else {
|
|
487
663
|
bytes = enc.encode(stored);
|
|
@@ -493,23 +669,29 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
493
669
|
// the boundary at or before it, and it is what the response reports back
|
|
494
670
|
// as `offset` (issue #38).
|
|
495
671
|
const offset = start + alignStartToCharBoundary(bytes, requestedOffset - start);
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
// Both sides are validated by now — the argument above, `globalCap` at
|
|
499
|
-
// intake — so `offset + maxBytes` always reaches past `offset`.
|
|
672
|
+
// Both sides of `maxBytes` are validated by now — the argument above, the
|
|
673
|
+
// cap at intake — so `offset + maxBytes` always reaches past `offset`.
|
|
500
674
|
// Align the slice end to a codepoint boundary so a multi-byte char is
|
|
501
675
|
// never split across pages (which would emit U+FFFD on both sides).
|
|
502
676
|
// `nextOffset` is this aligned end, so it is a valid boundary for the
|
|
503
677
|
// next call and paging reassembles the original byte-for-byte.
|
|
504
678
|
const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
|
|
505
679
|
const slice = dec.decode(bytes.subarray(offset - start, end - start));
|
|
506
|
-
const
|
|
507
|
-
|
|
680
|
+
const hasMore = end < total;
|
|
681
|
+
// The same notice-first shape as a truncated call: one line of header,
|
|
682
|
+
// then the page as raw text. A JSON `text` field escaped every quote and
|
|
683
|
+
// newline in the page — a second layer over JSON payloads — which
|
|
684
|
+
// inflated pages past the size clients accept and made them hard to read.
|
|
685
|
+
return noticeFirst({
|
|
686
|
+
resultId: args.id,
|
|
508
687
|
offset,
|
|
509
|
-
|
|
688
|
+
bytes: Math.max(0, end - offset),
|
|
510
689
|
totalBytes: total,
|
|
511
|
-
|
|
512
|
-
|
|
690
|
+
hasMore,
|
|
691
|
+
...(hasMore
|
|
692
|
+
? { nextAction: { tool: "get_result", arguments: { id: args.id, offset: end } } }
|
|
693
|
+
: {}),
|
|
694
|
+
}, slice);
|
|
513
695
|
},
|
|
514
696
|
async authorizeConnector(args) {
|
|
515
697
|
const connector = registry.getConnector(args.connector);
|
|
@@ -601,7 +783,7 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
601
783
|
const SEARCH_DESC = `Use top-level search for catalog inspection or approval-required work before call_destructive_tool. Unknown-address read-only work belongs in one execute_code program that searches, calls, and returns the answer. Use 2–4 action/object terms and includeSchemas="compact"; the default limit is ${DEFAULT_SEARCH_LIMIT}. Set connector when known. safety="readOnly" finds direct or program calls; "approvalRequired" finds the fail-closed complement. These filters grant no authority. Empty query browses.`;
|
|
602
784
|
const CALL_DESC = 'Call one known-address tool explicitly annotated readOnlyHint: true. Use execute_code for unknown-address, multiple, dependent, or reduced read-only work. Unannotated or write-capable tools fail closed to call_destructive_tool. A truncated result carries a get_result action.';
|
|
603
785
|
const CALL_DESTRUCTIVE_DESC = "Call any tool not explicitly annotated readOnlyHint: true. Include a short reason for the human reviewer after checking the schema and consequences. The reason grants no authority and is not sent downstream.";
|
|
604
|
-
const GET_RESULT_DESC = "Page a truncated direct-call result by id and byte offset. A program result is never paged; reduce it inside execute_code. Returns
|
|
786
|
+
const GET_RESULT_DESC = "Page a truncated direct-call result by id and byte offset. A program result is never paged; reduce it inside execute_code. Returns a one-line JSON header (offset, bytes, totalBytes, hasMore, nextAction) and then the page as raw text. maxBytes is an upper bound, clamped to the result's inline cap.";
|
|
605
787
|
const AUTHORIZE_DESC = "Use after auth_required. Returns an OAuth or operator-credential handoff, or reports required deployment configuration. force=true restarts OAuth only; this tool never accepts credentials.";
|
|
606
788
|
const SKILLS_DESC = 'List or fetch on-demand guidance. Fetch usage only when the always-loaded instructions are insufficient or a program needs repair.';
|
|
607
789
|
/**
|
|
@@ -648,6 +830,42 @@ const CALL_INPUT_SCHEMA = {
|
|
|
648
830
|
timeoutMs: z.number().int().positive().optional(),
|
|
649
831
|
diagnostics: z.boolean().optional(),
|
|
650
832
|
};
|
|
833
|
+
// The six input schemas, built once at module scope so their JSON Schema is
|
|
834
|
+
// derived once per process (see advertisedSchema). Nothing here may read
|
|
835
|
+
// deployment configuration or the request's identity: anything that varies
|
|
836
|
+
// belongs in the per-registration description instead.
|
|
837
|
+
const SKILLS_INPUT = advertisedSchema(z.object({ name: z.string().optional() }));
|
|
838
|
+
const SEARCH_INPUT = advertisedSchema(z.object({
|
|
839
|
+
query: z.string().optional(),
|
|
840
|
+
connector: z.string().optional(),
|
|
841
|
+
safety: z.enum(["readOnly", "approvalRequired", "all"]).optional(),
|
|
842
|
+
limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
|
|
843
|
+
offset: z.number().int().nonnegative().optional(),
|
|
844
|
+
fullDescriptions: z.boolean().optional(),
|
|
845
|
+
includeSchemas: z.enum(["compact", "json", "typescript"]).optional(),
|
|
846
|
+
}));
|
|
847
|
+
const CALL_INPUT = advertisedSchema(z.strictObject(CALL_INPUT_SCHEMA));
|
|
848
|
+
const CALL_DESTRUCTIVE_INPUT = advertisedSchema(z.strictObject({
|
|
849
|
+
...CALL_INPUT_SCHEMA,
|
|
850
|
+
// Bounded above, but with no lower bound: a model that sends `""` or
|
|
851
|
+
// whitespace has written no reason, and failing an entire consequential
|
|
852
|
+
// call over a cosmetic field the host merely displays is the wrong
|
|
853
|
+
// trade. It is normalized to absent in the handler instead.
|
|
854
|
+
reason: z.string().max(500).optional(),
|
|
855
|
+
}));
|
|
856
|
+
const AUTHORIZE_INPUT = advertisedSchema(z.object({
|
|
857
|
+
connector: z.string(),
|
|
858
|
+
force: z.boolean().optional(),
|
|
859
|
+
}));
|
|
860
|
+
const GET_RESULT_INPUT = advertisedSchema(z.object({
|
|
861
|
+
id: z.string(),
|
|
862
|
+
// Both bounds are the shared rules (isValidResultOffset,
|
|
863
|
+
// isValidMaxResultBytes) expressed for the wire: spelling them against
|
|
864
|
+
// the same constants keeps the schema from drifting away from the
|
|
865
|
+
// in-handler checks if either floor ever moves.
|
|
866
|
+
offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
|
|
867
|
+
maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
|
|
868
|
+
}));
|
|
651
869
|
/**
|
|
652
870
|
* Register the six explicit meta-tools onto an McpServer instance.
|
|
653
871
|
* `registerExecuteTool` adds the seventh, `execute_code`. Broad discovery and
|
|
@@ -669,41 +887,24 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
669
887
|
});
|
|
670
888
|
server.registerTool("skills", {
|
|
671
889
|
description: describedFor(registry, SKILLS_DESC, "skills"),
|
|
672
|
-
inputSchema:
|
|
890
|
+
inputSchema: SKILLS_INPUT,
|
|
673
891
|
annotations: READ_ONLY_LOCAL,
|
|
674
892
|
}, async (args) => mt.skills(args));
|
|
675
893
|
server.registerTool("search_tools", {
|
|
676
894
|
description: describedFor(registry, SEARCH_DESC, "search"),
|
|
677
|
-
inputSchema:
|
|
678
|
-
query: z.string().optional(),
|
|
679
|
-
connector: z.string().optional(),
|
|
680
|
-
safety: z
|
|
681
|
-
.enum(["readOnly", "approvalRequired", "all"])
|
|
682
|
-
.optional(),
|
|
683
|
-
limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
|
|
684
|
-
offset: z.number().int().nonnegative().optional(),
|
|
685
|
-
fullDescriptions: z.boolean().optional(),
|
|
686
|
-
includeSchemas: z.enum(["compact", "json"]).optional(),
|
|
687
|
-
}),
|
|
895
|
+
inputSchema: SEARCH_INPUT,
|
|
688
896
|
annotations: READ_ONLY_REMOTE,
|
|
689
897
|
}, async (args) => mt.searchTools(args));
|
|
690
898
|
server.registerTool("call_tool", {
|
|
691
899
|
description: CALL_DESC,
|
|
692
|
-
inputSchema:
|
|
900
|
+
inputSchema: CALL_INPUT,
|
|
693
901
|
// call_tool admits only tools that are themselves explicitly read-only;
|
|
694
902
|
// anything else is refused and routed to call_destructive_tool.
|
|
695
903
|
annotations: READ_ONLY_REMOTE,
|
|
696
904
|
}, async (args) => mt.callTool(args));
|
|
697
905
|
server.registerTool("call_destructive_tool", {
|
|
698
906
|
description: describedFor(registry, CALL_DESTRUCTIVE_DESC, "destructive"),
|
|
699
|
-
inputSchema:
|
|
700
|
-
...CALL_INPUT_SCHEMA,
|
|
701
|
-
// Bounded above, but with no lower bound: a model that sends `""` or
|
|
702
|
-
// whitespace has written no reason, and failing an entire consequential
|
|
703
|
-
// call over a cosmetic field the host merely displays is the wrong
|
|
704
|
-
// trade. It is normalized to absent below instead.
|
|
705
|
-
reason: z.string().max(500).optional(),
|
|
706
|
-
}),
|
|
907
|
+
inputSchema: CALL_DESTRUCTIVE_INPUT,
|
|
707
908
|
annotations: {
|
|
708
909
|
destructiveHint: true,
|
|
709
910
|
readOnlyHint: false,
|
|
@@ -720,10 +921,7 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
720
921
|
});
|
|
721
922
|
server.registerTool("authorize_connector", {
|
|
722
923
|
description: AUTHORIZE_DESC,
|
|
723
|
-
inputSchema:
|
|
724
|
-
connector: z.string(),
|
|
725
|
-
force: z.boolean().optional(),
|
|
726
|
-
}),
|
|
924
|
+
inputSchema: AUTHORIZE_INPUT,
|
|
727
925
|
// Starts (or with force, resets) a downstream OAuth flow — it changes
|
|
728
926
|
// stored connector auth state, so it is deliberately not read-only.
|
|
729
927
|
annotations: {
|
|
@@ -734,15 +932,7 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
734
932
|
}, async (args) => mt.authorizeConnector(args));
|
|
735
933
|
server.registerTool("get_result", {
|
|
736
934
|
description: GET_RESULT_DESC,
|
|
737
|
-
inputSchema:
|
|
738
|
-
id: z.string(),
|
|
739
|
-
// Both bounds are the shared rules (isValidResultOffset,
|
|
740
|
-
// isValidMaxResultBytes) expressed for the wire: spelling them against
|
|
741
|
-
// the same constants keeps the schema from drifting away from the
|
|
742
|
-
// in-handler checks if either floor ever moves.
|
|
743
|
-
offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
|
|
744
|
-
maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
|
|
745
|
-
}),
|
|
935
|
+
inputSchema: GET_RESULT_INPUT,
|
|
746
936
|
annotations: READ_ONLY_LOCAL,
|
|
747
937
|
}, async (args) => mt.getResult(args));
|
|
748
938
|
}
|