@tiangong-ai/cli 0.0.19 → 0.0.21

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 (58) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +209 -4
  3. package/dist/cli.js +2 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/research/commands.js +6 -0
  6. package/dist/research/commands.js.map +1 -1
  7. package/dist/research/orchestration.d.ts +3 -0
  8. package/dist/research/orchestration.js +391 -0
  9. package/dist/research/orchestration.js.map +1 -0
  10. package/dist/research/workspace/broker.d.ts +5 -0
  11. package/dist/research/workspace/broker.js +729 -0
  12. package/dist/research/workspace/broker.js.map +1 -0
  13. package/dist/research/workspace/capabilities.d.ts +10 -0
  14. package/dist/research/workspace/capabilities.js +356 -0
  15. package/dist/research/workspace/capabilities.js.map +1 -0
  16. package/dist/research/workspace/constants.d.ts +8 -0
  17. package/dist/research/workspace/constants.js +41 -0
  18. package/dist/research/workspace/constants.js.map +1 -0
  19. package/dist/research/workspace/context.d.ts +3 -0
  20. package/dist/research/workspace/context.js +77 -0
  21. package/dist/research/workspace/context.js.map +1 -0
  22. package/dist/research/workspace/evidence.d.ts +32 -0
  23. package/dist/research/workspace/evidence.js +235 -0
  24. package/dist/research/workspace/evidence.js.map +1 -0
  25. package/dist/research/workspace/executor.d.ts +22 -0
  26. package/dist/research/workspace/executor.js +926 -0
  27. package/dist/research/workspace/executor.js.map +1 -0
  28. package/dist/research/workspace/input-plan.d.ts +5 -0
  29. package/dist/research/workspace/input-plan.js +319 -0
  30. package/dist/research/workspace/input-plan.js.map +1 -0
  31. package/dist/research/workspace/journal.d.ts +7 -0
  32. package/dist/research/workspace/journal.js +105 -0
  33. package/dist/research/workspace/journal.js.map +1 -0
  34. package/dist/research/workspace/preflight.d.ts +108 -0
  35. package/dist/research/workspace/preflight.js +261 -0
  36. package/dist/research/workspace/preflight.js.map +1 -0
  37. package/dist/research/workspace/projects.d.ts +12 -0
  38. package/dist/research/workspace/projects.js +514 -0
  39. package/dist/research/workspace/projects.js.map +1 -0
  40. package/dist/research/workspace/runtime.d.ts +31 -0
  41. package/dist/research/workspace/runtime.js +1637 -0
  42. package/dist/research/workspace/runtime.js.map +1 -0
  43. package/dist/research/workspace/sanitization.d.ts +5 -0
  44. package/dist/research/workspace/sanitization.js +72 -0
  45. package/dist/research/workspace/sanitization.js.map +1 -0
  46. package/dist/research/workspace/schemas.d.ts +17 -0
  47. package/dist/research/workspace/schemas.js +342 -0
  48. package/dist/research/workspace/schemas.js.map +1 -0
  49. package/dist/research/workspace/storage.d.ts +25 -0
  50. package/dist/research/workspace/storage.js +222 -0
  51. package/dist/research/workspace/storage.js.map +1 -0
  52. package/dist/research/workspace/types.d.ts +341 -0
  53. package/dist/research/workspace/types.js +2 -0
  54. package/dist/research/workspace/types.js.map +1 -0
  55. package/dist/research/workspace/workspace.d.ts +23 -0
  56. package/dist/research/workspace/workspace.js +702 -0
  57. package/dist/research/workspace/workspace.js.map +1 -0
  58. package/package.json +4 -2
@@ -0,0 +1,729 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { dirname } from "node:path";
5
+ import { CliError } from "../../errors.js";
6
+ import { loadCapabilityDeclarations, verifyCapabilities } from "./capabilities.js";
7
+ import { loadBrokerEvidenceCache, persistBrokerEvidence, storeBrokerEvidenceCache, } from "./evidence.js";
8
+ import { appendJournalEvent } from "./journal.js";
9
+ import { sanitizeResearchRecord, sanitizeResearchText } from "./sanitization.js";
10
+ import { canonicalJson, ensureDirectory, isObject, pathExists, resolveContained, sha256Text, workspacePaths, } from "./storage.js";
11
+ import { loadWorkspaceConfig } from "./workspace.js";
12
+ const MAX_REQUEST_BYTES = 1024 * 1024;
13
+ const MAX_ERROR_RESPONSE_BYTES = 16 * 1024;
14
+ const BROKER_CONTEXT_BYTES_PER_TOKEN = 3;
15
+ export async function startCapabilityBroker(root, projectId, capsuleProject) {
16
+ const declarations = await loadCapabilityDeclarations(root);
17
+ const networkCapabilities = declarations.capabilities.filter((capability) => capability.permissions.includes("brokered-network"));
18
+ if (!networkCapabilities.length)
19
+ return undefined;
20
+ const verification = await verifyCapabilities(root);
21
+ if (verification.status !== "verified") {
22
+ throw new CliError("Capability broker requires verified capability locks.", {
23
+ code: "RESEARCH_CAPABILITY_DRIFT",
24
+ exitCode: 3,
25
+ details: verification,
26
+ });
27
+ }
28
+ const credentialMap = await loadCredentialMap(root, declarations.capabilities);
29
+ const config = await loadWorkspaceConfig(root);
30
+ const routeToken = randomUUID().replaceAll("-", "");
31
+ const route = `/mcp/${routeToken}`;
32
+ const server = createServer((request, response) => {
33
+ void handleMcpRequest({
34
+ request,
35
+ response,
36
+ route,
37
+ root,
38
+ projectId,
39
+ capsuleProject,
40
+ capabilities: networkCapabilities,
41
+ credentialMap,
42
+ workspaceResponseBytes: config.budget.maxBrokerResponseBytes,
43
+ workspaceContextTokens: config.budget.maxBrokerContextTokens,
44
+ workspaceMaxItems: config.budget.maxBrokerItems,
45
+ });
46
+ });
47
+ await new Promise((resolvePromise, reject) => {
48
+ server.once("error", reject);
49
+ server.listen(0, "127.0.0.1", () => resolvePromise());
50
+ });
51
+ const address = server.address();
52
+ if (!address || typeof address === "string") {
53
+ server.close();
54
+ throw new Error("Capability broker did not receive a TCP address.");
55
+ }
56
+ return {
57
+ url: `http://127.0.0.1:${address.port}${route}`,
58
+ stop: () => new Promise((resolvePromise, reject) => {
59
+ server.close((error) => (error ? reject(error) : resolvePromise()));
60
+ }),
61
+ };
62
+ }
63
+ async function handleMcpRequest(input) {
64
+ try {
65
+ if (input.request.method !== "POST" || input.request.url !== input.route) {
66
+ sendJson(input.response, 404, { error: "not_found" });
67
+ return;
68
+ }
69
+ const body = await readRequestJson(input.request);
70
+ if (!isObject(body) || body.jsonrpc !== "2.0" || typeof body.method !== "string") {
71
+ sendRpcError(input.response, body, -32600, "Invalid Request");
72
+ return;
73
+ }
74
+ if (body.method === "notifications/initialized") {
75
+ input.response.writeHead(202).end();
76
+ return;
77
+ }
78
+ if (body.method === "initialize") {
79
+ sendRpcResult(input.response, body, {
80
+ protocolVersion: "2025-03-26",
81
+ capabilities: { tools: {} },
82
+ serverInfo: { name: "tiangong-research-broker", version: "1" },
83
+ });
84
+ return;
85
+ }
86
+ if (body.method === "tools/list") {
87
+ sendRpcResult(input.response, body, {
88
+ tools: [
89
+ {
90
+ name: "fetch_candidate_source",
91
+ description: "Fetch one bounded HTTPS candidate source through a locked capability, persist the raw response, and return content-addressed provenance plus a bounded context view.",
92
+ inputSchema: {
93
+ type: "object",
94
+ additionalProperties: false,
95
+ required: ["capability_id", "url"],
96
+ properties: {
97
+ capability_id: { type: "string" },
98
+ credential_id: { type: "string" },
99
+ url: { type: "string" },
100
+ json_pointer: { type: "string" },
101
+ item_offset: { type: "integer", minimum: 0 },
102
+ max_items: { type: "integer", minimum: 1 },
103
+ cache_mode: { enum: ["prefer", "bypass"] },
104
+ },
105
+ },
106
+ },
107
+ ],
108
+ });
109
+ return;
110
+ }
111
+ if (body.method === "tools/call") {
112
+ const params = body.params;
113
+ if (!isObject(params) ||
114
+ params.name !== "fetch_candidate_source" ||
115
+ !isObject(params.arguments)) {
116
+ sendToolError(input.response, body, "Unsupported tool call.");
117
+ return;
118
+ }
119
+ try {
120
+ const receipt = await fetchCandidateSource({
121
+ root: input.root,
122
+ projectId: input.projectId,
123
+ capsuleProject: input.capsuleProject,
124
+ capabilities: input.capabilities,
125
+ credentialMap: input.credentialMap,
126
+ workspaceResponseBytes: input.workspaceResponseBytes,
127
+ workspaceContextTokens: input.workspaceContextTokens,
128
+ workspaceMaxItems: input.workspaceMaxItems,
129
+ arguments: params.arguments,
130
+ });
131
+ sendRpcResult(input.response, body, {
132
+ content: [{ type: "text", text: JSON.stringify(receipt) }],
133
+ });
134
+ }
135
+ catch (error) {
136
+ const detail = error instanceof CliError
137
+ ? JSON.stringify({ code: error.code, message: error.message, details: error.details })
138
+ : error instanceof Error
139
+ ? error.message
140
+ : String(error);
141
+ sendToolError(input.response, body, sanitizeResearchText(detail, [...input.credentialMap.values()]));
142
+ }
143
+ return;
144
+ }
145
+ sendRpcError(input.response, body, -32601, "Method not found");
146
+ }
147
+ catch (error) {
148
+ sendJson(input.response, 500, {
149
+ error: sanitizeResearchText(error instanceof Error ? error.message : String(error), [
150
+ ...input.credentialMap.values(),
151
+ ]),
152
+ });
153
+ }
154
+ }
155
+ async function fetchCandidateSource(input) {
156
+ const capabilityId = input.arguments.capability_id;
157
+ const credentialId = input.arguments.credential_id;
158
+ const rawUrl = input.arguments.url;
159
+ const jsonPointer = input.arguments.json_pointer;
160
+ const requestedItemOffset = input.arguments.item_offset ?? 0;
161
+ const requestedMaxItems = input.arguments.max_items;
162
+ const cacheMode = input.arguments.cache_mode ?? "prefer";
163
+ if (typeof capabilityId !== "string" || typeof rawUrl !== "string") {
164
+ throw new Error("capability_id and url are required strings");
165
+ }
166
+ if (credentialId !== undefined && typeof credentialId !== "string") {
167
+ throw new Error("credential_id must be a string when provided");
168
+ }
169
+ if (jsonPointer !== undefined &&
170
+ (typeof jsonPointer !== "string" || !validJsonPointer(jsonPointer))) {
171
+ throw new Error("json_pointer must be an RFC 6901 JSON Pointer when provided");
172
+ }
173
+ if (typeof requestedItemOffset !== "number" ||
174
+ !Number.isInteger(requestedItemOffset) ||
175
+ requestedItemOffset < 0) {
176
+ throw new Error("item_offset must be a non-negative integer when provided");
177
+ }
178
+ if (requestedMaxItems !== undefined &&
179
+ (typeof requestedMaxItems !== "number" ||
180
+ !Number.isInteger(requestedMaxItems) ||
181
+ requestedMaxItems < 1)) {
182
+ throw new Error("max_items must be a positive integer when provided");
183
+ }
184
+ if (cacheMode !== "prefer" && cacheMode !== "bypass") {
185
+ throw new Error('cache_mode must be "prefer" or "bypass"');
186
+ }
187
+ const capability = input.capabilities.find((candidate) => candidate.id === capabilityId);
188
+ if (!capability)
189
+ throw new Error(`capability is not admitted for brokered network: ${capabilityId}`);
190
+ const target = validateHttpsUrl(rawUrl);
191
+ if (!capability.http)
192
+ throw new Error(`capability has no broker HTTP policy: ${capabilityId}`);
193
+ const credential = credentialId
194
+ ? capability.credentials.find((candidate) => candidate.id === credentialId)
195
+ : undefined;
196
+ if (credentialId && !credential) {
197
+ throw new Error(`credential is not declared by capability ${capabilityId}: ${credentialId}`);
198
+ }
199
+ if (credential && cacheMode === "prefer") {
200
+ throw new Error("credentialed broker requests require cache_mode=bypass");
201
+ }
202
+ assertAllowedHost(target, capability.allowedHosts, "capability");
203
+ if (credential)
204
+ assertAllowedHost(target, credential.allowedHosts, "credential");
205
+ const attemptId = randomUUID();
206
+ const maxItems = Math.min(requestedMaxItems ?? capability.http.maxItems, capability.http.maxItems, input.workspaceMaxItems);
207
+ const cacheKeySha256 = sha256Text(canonicalJson({
208
+ capabilityId,
209
+ targetSha256: sha256Text(target.toString()),
210
+ accept: capability.http.accept,
211
+ }));
212
+ await appendJournalEvent(workspacePaths(input.root).journal, "capability.fetch.attempted", input.projectId, {
213
+ attemptId,
214
+ projectId: input.projectId,
215
+ capabilityId,
216
+ credentialId: credentialId ?? null,
217
+ targetSha256: sha256Text(target.toString()),
218
+ cacheMode,
219
+ cacheKeySha256,
220
+ });
221
+ try {
222
+ if (cacheMode === "prefer") {
223
+ const cached = await loadBrokerEvidenceCache(input.root, cacheKeySha256);
224
+ if (cached) {
225
+ const raw = await readFile(resolveContained(workspacePaths(input.root).control, cached.locator));
226
+ const context = buildContextView(raw, cached.contentType, jsonPointer, requestedItemOffset, maxItems, input.workspaceContextTokens * BROKER_CONTEXT_BYTES_PER_TOKEN);
227
+ const receipt = await persistBrokerEvidence(input.root, {
228
+ attemptId,
229
+ projectId: input.projectId,
230
+ capabilityId,
231
+ credentialId: null,
232
+ status: cached.status,
233
+ contentType: cached.contentType,
234
+ sourceSha256: cached.sourceSha256,
235
+ contextItems: context.items,
236
+ contextOffset: context.offset,
237
+ contextTotalItems: context.totalItems,
238
+ contextNextOffset: context.nextOffset,
239
+ contextTruncated: context.truncated,
240
+ retrievedAt: cached.retrievedAt,
241
+ cacheHit: true,
242
+ }, raw, context.bytes);
243
+ await stageContextObject(input.capsuleProject, receipt.contextLocator, context.bytes);
244
+ await appendCompletedReceipt(input.root, input.projectId, receipt, cacheKeySha256);
245
+ return { ...receipt };
246
+ }
247
+ }
248
+ const headers = new Headers({ Accept: capability.http.accept });
249
+ if (credential) {
250
+ const value = input.credentialMap.get(credential.id);
251
+ if (!value)
252
+ throw new Error(`credential value is not configured: ${credential.id}`);
253
+ headers.set(credential.headerName, `${credential.prefix}${value}`);
254
+ }
255
+ const { response, finalUrl } = await fetchWithRedirectPolicy(target, headers, capability.allowedHosts, credential?.allowedHosts);
256
+ const responseLimit = Math.min(capability.http.maxResponseBytes, input.workspaceResponseBytes);
257
+ const announcedLength = Number(response.headers.get("content-length") ?? "0");
258
+ if (response.ok && announcedLength > responseLimit)
259
+ throw new Error("response exceeds the broker size limit");
260
+ const bytes = await readBoundedResponseBody(response, response.ok ? responseLimit : Math.min(responseLimit, MAX_ERROR_RESPONSE_BYTES), !response.ok);
261
+ for (const secret of input.credentialMap.values()) {
262
+ if (bytes.includes(Buffer.from(secret, "utf8"))) {
263
+ throw new Error("response failed credential disclosure screening");
264
+ }
265
+ }
266
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim() ?? "application/octet-stream";
267
+ if (!response.ok) {
268
+ const retryAfterSeconds = parseRetryAfter(response.headers.get("retry-after"));
269
+ const excerpt = safeResponseExcerpt(bytes, contentType, [...input.credentialMap.values()]);
270
+ throw new CliError(`HTTPS source returned status ${response.status}.`, {
271
+ code: "RESEARCH_BROKER_HTTP_ERROR",
272
+ exitCode: 3,
273
+ details: {
274
+ status: response.status,
275
+ retryAfterSeconds,
276
+ responseExcerpt: excerpt,
277
+ requestId: safeResponseId(response.headers),
278
+ },
279
+ });
280
+ }
281
+ if (!contentTypeAllowed(contentType, capability.http.allowedContentTypes)) {
282
+ throw new CliError(`HTTPS source returned unsupported content type ${contentType}.`, {
283
+ code: "RESEARCH_BROKER_CONTENT_TYPE_INVALID",
284
+ exitCode: 3,
285
+ details: { contentType, allowedContentTypes: capability.http.allowedContentTypes },
286
+ });
287
+ }
288
+ assertNoSensitiveResponseMaterial(bytes, contentType);
289
+ const context = buildContextView(bytes, contentType, jsonPointer, requestedItemOffset, maxItems, input.workspaceContextTokens * BROKER_CONTEXT_BYTES_PER_TOKEN);
290
+ const receipt = await persistBrokerEvidence(input.root, {
291
+ attemptId,
292
+ projectId: input.projectId,
293
+ capabilityId,
294
+ credentialId: credentialId ?? null,
295
+ status: response.status,
296
+ contentType,
297
+ sourceSha256: sha256Text(finalUrl.toString()),
298
+ contextItems: context.items,
299
+ contextOffset: context.offset,
300
+ contextTotalItems: context.totalItems,
301
+ contextNextOffset: context.nextOffset,
302
+ contextTruncated: context.truncated,
303
+ retrievedAt: new Date().toISOString(),
304
+ cacheHit: false,
305
+ }, bytes, context.bytes);
306
+ await stageContextObject(input.capsuleProject, receipt.contextLocator, context.bytes);
307
+ if (!credential)
308
+ await storeBrokerEvidenceCache(input.root, cacheKeySha256, receipt);
309
+ await appendCompletedReceipt(input.root, input.projectId, receipt, cacheKeySha256);
310
+ return { ...receipt };
311
+ }
312
+ catch (error) {
313
+ const safeDetails = error instanceof CliError && isObject(error.details)
314
+ ? sanitizeResearchRecord(error.details, [...input.credentialMap.values()])
315
+ : {};
316
+ await appendJournalEvent(workspacePaths(input.root).journal, "capability.fetch.failed", input.projectId, {
317
+ attemptId,
318
+ capabilityId,
319
+ credentialId: credentialId ?? null,
320
+ error: bounded(sanitizeResearchText(error instanceof Error ? error.message : String(error), [
321
+ ...input.credentialMap.values(),
322
+ ]), 500),
323
+ failureKind: brokerFailureKind(error),
324
+ ...safeDetails,
325
+ });
326
+ throw error;
327
+ }
328
+ }
329
+ function assertNoSensitiveResponseMaterial(bytes, contentType) {
330
+ if (!contentType.includes("json") && !contentType.startsWith("text/"))
331
+ return;
332
+ const text = bytes.toString("utf8");
333
+ if (/\b(Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+=*/i.test(text) ||
334
+ /\b(authorization|cookie|set-cookie|x-api-key|api-key)\s*:/i.test(text) ||
335
+ /\b(access_token|api[_-]?key|apikey|password|secret|session|token)\s*=/i.test(text)) {
336
+ throw new Error("response contains credential-like material and was not persisted");
337
+ }
338
+ if (!contentType.includes("json"))
339
+ return;
340
+ try {
341
+ const value = JSON.parse(text);
342
+ if (containsSensitiveJsonField(value)) {
343
+ throw new Error("response contains credential-like material and was not persisted");
344
+ }
345
+ }
346
+ catch (error) {
347
+ if (error instanceof SyntaxError)
348
+ return;
349
+ throw error;
350
+ }
351
+ }
352
+ function containsSensitiveJsonField(value) {
353
+ if (Array.isArray(value))
354
+ return value.some(containsSensitiveJsonField);
355
+ if (!isObject(value))
356
+ return false;
357
+ const sensitive = /^(access_token|api[_-]?key|apikey|authorization|cookie|password|secret|session|token)$/i;
358
+ return Object.entries(value).some(([key, item]) => (sensitive.test(key) && item !== null && item !== "") || containsSensitiveJsonField(item));
359
+ }
360
+ async function stageContextObject(capsuleProject, locator, bytes) {
361
+ const destination = resolveContained(capsuleProject, locator);
362
+ await ensureDirectory(dirname(destination));
363
+ try {
364
+ await writeFile(destination, bytes, { mode: 0o600, flag: "wx" });
365
+ }
366
+ catch (error) {
367
+ if (error.code !== "EEXIST")
368
+ throw error;
369
+ if (!Buffer.from(await readFile(destination)).equals(Buffer.from(bytes))) {
370
+ throw new Error("staged broker context object failed its integrity check");
371
+ }
372
+ }
373
+ }
374
+ async function appendCompletedReceipt(root, projectId, receipt, cacheKeySha256) {
375
+ await appendJournalEvent(workspacePaths(root).journal, "capability.fetch.completed", projectId, {
376
+ attemptId: receipt.attemptId,
377
+ projectId: receipt.projectId,
378
+ capabilityId: receipt.capabilityId,
379
+ credentialId: receipt.credentialId,
380
+ status: receipt.status,
381
+ contentType: receipt.contentType,
382
+ bytes: receipt.bytes,
383
+ sha256: receipt.sha256,
384
+ sourceSha256: receipt.sourceSha256,
385
+ locator: receipt.locator,
386
+ contextLocator: receipt.contextLocator,
387
+ contextSha256: receipt.contextSha256,
388
+ contextBytes: receipt.contextBytes,
389
+ contextEstimatedTokens: receipt.contextEstimatedTokens,
390
+ contextItems: receipt.contextItems,
391
+ contextOffset: receipt.contextOffset ?? 0,
392
+ contextTotalItems: receipt.contextTotalItems ?? null,
393
+ contextNextOffset: receipt.contextNextOffset ?? null,
394
+ contextTruncated: receipt.contextTruncated,
395
+ retrievedAt: receipt.retrievedAt,
396
+ servedAt: receipt.servedAt,
397
+ cacheHit: receipt.cacheHit,
398
+ cacheKeySha256,
399
+ });
400
+ }
401
+ async function loadCredentialMap(root, capabilities) {
402
+ const path = workspacePaths(root).env;
403
+ if (!(await pathExists(path)))
404
+ return new Map();
405
+ const content = await readFile(path, "utf8");
406
+ const configured = new Map();
407
+ let foundConfiguration = false;
408
+ const declared = new Set(capabilities.flatMap((capability) => capability.credentials.map((credential) => credential.id)));
409
+ for (const sourceLine of content.split(/\r?\n/)) {
410
+ const line = sourceLine.trim();
411
+ if (!line || line.startsWith("#"))
412
+ continue;
413
+ const equals = line.indexOf("=");
414
+ const key = equals > 0 ? line.slice(0, equals).trim() : "";
415
+ if (key !== "TIANGONG_RESEARCH_CAPABILITY_CREDENTIALS_JSON") {
416
+ throw new Error(`unsupported research environment key: ${key || "missing"}`);
417
+ }
418
+ if (foundConfiguration)
419
+ throw new Error("research credential configuration is duplicated");
420
+ foundConfiguration = true;
421
+ const value = JSON.parse(line.slice(equals + 1).trim() || "{}");
422
+ if (!isObject(value))
423
+ throw new Error("capability credentials must be a JSON object");
424
+ for (const [credentialId, credentialValue] of Object.entries(value)) {
425
+ if (!declared.has(credentialId))
426
+ throw new Error(`credential is not declared: ${credentialId}`);
427
+ if (typeof credentialValue !== "string" || Buffer.byteLength(credentialValue, "utf8") < 8) {
428
+ throw new Error(`credential value is invalid: ${credentialId}`);
429
+ }
430
+ configured.set(credentialId, credentialValue);
431
+ }
432
+ }
433
+ return configured;
434
+ }
435
+ async function fetchWithRedirectPolicy(initialUrl, headers, capabilityHosts, credentialHosts) {
436
+ let current = initialUrl;
437
+ for (let redirects = 0; redirects <= 5; redirects += 1) {
438
+ assertAllowedHost(current, capabilityHosts, "capability");
439
+ if (credentialHosts)
440
+ assertAllowedHost(current, credentialHosts, "credential");
441
+ const response = await fetch(current, {
442
+ method: "GET",
443
+ headers,
444
+ redirect: "manual",
445
+ signal: AbortSignal.timeout(120_000),
446
+ });
447
+ if (![301, 302, 303, 307, 308].includes(response.status)) {
448
+ return { response, finalUrl: current };
449
+ }
450
+ const location = response.headers.get("location");
451
+ await response.body?.cancel();
452
+ if (!location)
453
+ throw new Error("HTTPS source returned a redirect without a location");
454
+ if (redirects === 5)
455
+ throw new Error("HTTPS source exceeded the redirect limit");
456
+ current = validateHttpsUrl(new URL(location, current).toString());
457
+ }
458
+ throw new Error("HTTPS source exceeded the redirect limit");
459
+ }
460
+ function assertAllowedHost(url, allowedHosts, scope) {
461
+ if (!allowedHosts.includes(url.host)) {
462
+ throw new Error(`target host is outside ${scope} scope: ${url.host}`);
463
+ }
464
+ }
465
+ async function readBoundedResponseBody(response, maxBytes, truncate = false) {
466
+ if (!response.body)
467
+ return Buffer.alloc(0);
468
+ const reader = response.body.getReader();
469
+ const chunks = [];
470
+ let bytes = 0;
471
+ try {
472
+ while (true) {
473
+ const result = await reader.read();
474
+ if (result.done)
475
+ break;
476
+ const chunk = Buffer.from(result.value);
477
+ bytes += chunk.length;
478
+ if (bytes > maxBytes) {
479
+ await reader.cancel();
480
+ if (truncate) {
481
+ const remaining = maxBytes - chunks.reduce((sum, item) => sum + item.length, 0);
482
+ if (remaining > 0)
483
+ chunks.push(chunk.subarray(0, remaining));
484
+ break;
485
+ }
486
+ throw new Error("response exceeds the broker size limit");
487
+ }
488
+ chunks.push(chunk);
489
+ }
490
+ }
491
+ finally {
492
+ reader.releaseLock();
493
+ }
494
+ return Buffer.concat(chunks);
495
+ }
496
+ function buildContextView(bytes, contentType, jsonPointer, itemOffset, maxItems, maxContextBytes) {
497
+ if (!Number.isInteger(maxContextBytes) || maxContextBytes < 1) {
498
+ throw new Error("broker context byte limit is invalid");
499
+ }
500
+ if (!contentType.includes("json")) {
501
+ if (itemOffset !== 0)
502
+ throw new Error("item_offset is supported only for JSON collections");
503
+ const boundedBytes = contentType.startsWith("text/")
504
+ ? truncateUtf8(bytes, maxContextBytes)
505
+ : bytes.subarray(0, maxContextBytes);
506
+ return {
507
+ bytes: boundedBytes,
508
+ items: null,
509
+ offset: 0,
510
+ totalItems: null,
511
+ nextOffset: null,
512
+ truncated: boundedBytes.byteLength < bytes.byteLength,
513
+ };
514
+ }
515
+ let parsed;
516
+ try {
517
+ parsed = JSON.parse(bytes.toString("utf8"));
518
+ }
519
+ catch {
520
+ throw new Error("JSON response body is not valid JSON");
521
+ }
522
+ const selected = jsonPointer === undefined ? parsed : resolveJsonPointer(parsed, jsonPointer);
523
+ if (Array.isArray(selected)) {
524
+ const limited = [];
525
+ for (const item of selected.slice(itemOffset, itemOffset + maxItems)) {
526
+ const candidate = [...limited, item];
527
+ if (jsonBytes(candidate).byteLength > maxContextBytes)
528
+ break;
529
+ limited.push(item);
530
+ }
531
+ const nextOffset = itemOffset + limited.length < selected.length ? itemOffset + limited.length : null;
532
+ return {
533
+ bytes: jsonBytes(limited),
534
+ items: limited.length,
535
+ offset: itemOffset,
536
+ totalItems: selected.length,
537
+ nextOffset,
538
+ truncated: itemOffset > 0 || nextOffset !== null,
539
+ };
540
+ }
541
+ if (isObject(selected)) {
542
+ const entries = Object.entries(selected);
543
+ const limited = [];
544
+ for (const entry of entries.slice(itemOffset, itemOffset + maxItems)) {
545
+ const candidate = [...limited, entry];
546
+ if (jsonBytes(Object.fromEntries(candidate)).byteLength > maxContextBytes)
547
+ break;
548
+ limited.push(entry);
549
+ }
550
+ const nextOffset = itemOffset + limited.length < entries.length ? itemOffset + limited.length : null;
551
+ return {
552
+ bytes: jsonBytes(Object.fromEntries(limited)),
553
+ items: limited.length,
554
+ offset: itemOffset,
555
+ totalItems: entries.length,
556
+ nextOffset,
557
+ truncated: itemOffset > 0 || nextOffset !== null,
558
+ };
559
+ }
560
+ if (itemOffset !== 0)
561
+ throw new Error("item_offset is supported only for JSON collections");
562
+ const encoded = jsonBytes(selected);
563
+ if (encoded.byteLength <= maxContextBytes) {
564
+ return {
565
+ bytes: encoded,
566
+ items: 1,
567
+ offset: 0,
568
+ totalItems: 1,
569
+ nextOffset: null,
570
+ truncated: false,
571
+ };
572
+ }
573
+ if (typeof selected !== "string") {
574
+ throw new Error("selected JSON scalar exceeds the broker context token limit");
575
+ }
576
+ const characters = [...selected];
577
+ let lower = 0;
578
+ let upper = characters.length;
579
+ while (lower < upper) {
580
+ const middle = Math.ceil((lower + upper) / 2);
581
+ if (jsonBytes(characters.slice(0, middle).join("")).byteLength <= maxContextBytes) {
582
+ lower = middle;
583
+ }
584
+ else {
585
+ upper = middle - 1;
586
+ }
587
+ }
588
+ return {
589
+ bytes: jsonBytes(characters.slice(0, lower).join("")),
590
+ items: 1,
591
+ offset: 0,
592
+ totalItems: 1,
593
+ nextOffset: null,
594
+ truncated: true,
595
+ };
596
+ }
597
+ function jsonBytes(value) {
598
+ return Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
599
+ }
600
+ function truncateUtf8(bytes, maxBytes) {
601
+ if (bytes.byteLength <= maxBytes)
602
+ return bytes;
603
+ let end = maxBytes;
604
+ const decoder = new TextDecoder("utf-8", { fatal: true });
605
+ while (end > 0) {
606
+ const candidate = bytes.subarray(0, end);
607
+ try {
608
+ decoder.decode(candidate);
609
+ return Buffer.from(candidate);
610
+ }
611
+ catch {
612
+ end -= 1;
613
+ }
614
+ }
615
+ return Buffer.alloc(0);
616
+ }
617
+ function resolveJsonPointer(value, pointer) {
618
+ if (pointer === "")
619
+ return value;
620
+ let selected = value;
621
+ for (const rawPart of pointer.slice(1).split("/")) {
622
+ const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~");
623
+ if (Array.isArray(selected) &&
624
+ /^(0|[1-9][0-9]*)$/.test(part) &&
625
+ Number(part) < selected.length) {
626
+ selected = selected[Number(part)];
627
+ }
628
+ else if (isObject(selected) && Object.hasOwn(selected, part)) {
629
+ selected = selected[part];
630
+ }
631
+ else {
632
+ throw new Error("json_pointer does not resolve within the response");
633
+ }
634
+ }
635
+ return selected;
636
+ }
637
+ function validJsonPointer(value) {
638
+ return value === "" || (value.startsWith("/") && !/~(?:[^01]|$)/.test(value));
639
+ }
640
+ function contentTypeAllowed(contentType, allowed) {
641
+ const normalized = contentType.toLowerCase();
642
+ return allowed.some((pattern) => {
643
+ const candidate = pattern.toLowerCase();
644
+ if (candidate === "*/*" || candidate === normalized)
645
+ return true;
646
+ const escaped = candidate.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
647
+ return new RegExp(`^${escaped}$`).test(normalized);
648
+ });
649
+ }
650
+ function safeResponseExcerpt(bytes, contentType, secrets) {
651
+ if (!contentType.includes("json") && !contentType.startsWith("text/")) {
652
+ return `[${bytes.length} non-text byte(s)]`;
653
+ }
654
+ return bounded(sanitizeResearchText(bytes.toString("utf8"), secrets).replace(/\s+/g, " "), 1000);
655
+ }
656
+ function safeResponseId(headers) {
657
+ for (const name of ["x-request-id", "request-id", "x-amzn-requestid", "cf-ray"]) {
658
+ const value = headers.get(name)?.trim();
659
+ if (value && /^[A-Za-z0-9._:-]{1,200}$/.test(value))
660
+ return value;
661
+ }
662
+ return null;
663
+ }
664
+ function parseRetryAfter(value) {
665
+ if (!value)
666
+ return null;
667
+ const seconds = Number(value);
668
+ if (Number.isFinite(seconds) && seconds >= 0)
669
+ return Math.ceil(seconds);
670
+ const date = Date.parse(value);
671
+ return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1000)) : null;
672
+ }
673
+ function brokerFailureKind(error) {
674
+ if (error instanceof CliError && error.code === "RESEARCH_BROKER_HTTP_ERROR") {
675
+ const status = isObject(error.details) ? error.details.status : undefined;
676
+ if (status === 429)
677
+ return "rate-limit";
678
+ if (typeof status === "number" && status >= 500)
679
+ return "server";
680
+ return "deterministic";
681
+ }
682
+ return error instanceof TypeError ? "transient" : "deterministic";
683
+ }
684
+ function validateHttpsUrl(value) {
685
+ const url = new URL(value);
686
+ if (url.protocol !== "https:" || url.username || url.password || !url.hostname) {
687
+ throw new Error("broker URL must be credential-free HTTPS");
688
+ }
689
+ return url;
690
+ }
691
+ async function readRequestJson(request) {
692
+ const chunks = [];
693
+ let bytes = 0;
694
+ for await (const chunk of request) {
695
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
696
+ bytes += buffer.length;
697
+ if (bytes > MAX_REQUEST_BYTES)
698
+ throw new Error("MCP request exceeds its size limit");
699
+ chunks.push(buffer);
700
+ }
701
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
702
+ }
703
+ function sendRpcResult(response, request, result) {
704
+ sendJson(response, 200, { jsonrpc: "2.0", id: request.id ?? null, result });
705
+ }
706
+ function sendRpcError(response, request, code, message) {
707
+ const id = isObject(request) ? (request.id ?? null) : null;
708
+ sendJson(response, 200, { jsonrpc: "2.0", id, error: { code, message } });
709
+ }
710
+ function sendToolError(response, request, message) {
711
+ sendRpcResult(response, request, {
712
+ content: [{ type: "text", text: message }],
713
+ isError: true,
714
+ });
715
+ }
716
+ function sendJson(response, status, value) {
717
+ if (response.headersSent)
718
+ return;
719
+ const body = `${JSON.stringify(value)}\n`;
720
+ response.writeHead(status, {
721
+ "content-type": "application/json",
722
+ "content-length": Buffer.byteLength(body),
723
+ });
724
+ response.end(body);
725
+ }
726
+ function bounded(value, length) {
727
+ return value.length <= length ? value : `${value.slice(0, length)}…`;
728
+ }
729
+ //# sourceMappingURL=broker.js.map