@expo/code-review-cli 0.12.3 → 0.12.5

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.
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
4
  import { ResearchAudit } from "./audit.js";
5
+ import { createResearchNetwork } from "./network.js";
5
6
  import { sanitizeDocumentationQuery } from "./query-sanitizer.js";
6
7
  import { DIRECT_DOCUMENT_CONTEXT_MODES, fetchDocumentationUrl, resolveDirectDocumentationTarget, } from "./direct-fetch.js";
7
8
  import { searchExpoAlgolia } from "./expo-algolia.js";
@@ -26,7 +27,9 @@ export async function createDocumentationServer(options = {}) {
26
27
  const index = options.indexPath ? await loadSearchIndex(options.indexPath) : undefined;
27
28
  const maxCalls = Math.min(20, Math.max(1, options.maxCalls ?? 8));
28
29
  const maxResultsPerCall = Math.min(3, Math.max(1, options.maxResultsPerCall ?? 3));
30
+ const timeoutMs = Math.min(60_000, Math.max(1_000, options.timeoutMs ?? 30_000));
29
31
  const audit = new ResearchAudit(options.auditPath, maxCalls);
32
+ const baseFetch = options.fetchImplementation ?? fetch;
30
33
  const server = new McpServer({
31
34
  name: "review-research-mcp",
32
35
  version: "0.2.0",
@@ -40,7 +43,9 @@ export async function createDocumentationServer(options = {}) {
40
43
  .default("all")
41
44
  .describe("Documentation platform to search"),
42
45
  query: z.string().min(1).max(300).describe(queryGuidance),
43
- limit: z.number().int().min(1).max(10).default(5),
46
+ // Advertise the limit this server actually enforces, so the caller's
47
+ // mental model matches what a request can return.
48
+ limit: z.number().int().min(1).max(maxResultsPerCall).default(maxResultsPerCall),
44
49
  language: z
45
50
  .enum(LANGUAGES)
46
51
  .optional()
@@ -65,9 +70,19 @@ export async function createDocumentationServer(options = {}) {
65
70
  openWorldHint: true,
66
71
  },
67
72
  }, async ({ platform, query, limit, language, providers, sourceKinds }) => {
68
- const sanitizedQuery = sanitizeDocumentationQuery(query);
73
+ let sanitizedQuery;
74
+ try {
75
+ sanitizedQuery = sanitizeDocumentationQuery(query);
76
+ }
77
+ catch (error) {
78
+ // Audited by reason class only: the rejected text may be exactly the
79
+ // sensitive material the sanitizer refused to send.
80
+ await audit.rejected("search_platform_docs", "query-rejected");
81
+ throw error;
82
+ }
69
83
  const selectedProviders = (providers ?? defaultProviders(platform)).filter((provider) => platform === "all" || getProvider(provider).platform === platform);
70
84
  if (selectedProviders.length === 0) {
85
+ await audit.rejected("search_platform_docs", "query-rejected");
71
86
  throw new Error("No selected documentation provider matches the requested platform");
72
87
  }
73
88
  const boundedLimit = Math.min(limit, maxResultsPerCall);
@@ -77,6 +92,8 @@ export async function createDocumentationServer(options = {}) {
77
92
  query: sanitizedQuery,
78
93
  };
79
94
  const requestId = await audit.reserve("search_platform_docs", auditInput);
95
+ // One deadline and one request ledger for everything this call issues.
96
+ const network = createResearchNetwork(baseFetch, timeoutMs);
80
97
  try {
81
98
  const localResults = index
82
99
  ? searchDocumentation(index, sanitizedQuery, {
@@ -94,10 +111,15 @@ export async function createDocumentationServer(options = {}) {
94
111
  const searched = await Promise.all(selectedProviders.map(async (provider) => {
95
112
  if (provider === "expo") {
96
113
  if (sourceKinds && !sourceKinds.includes("official-api")) {
97
- return { results: [], warnings: [] };
114
+ return {
115
+ results: [],
116
+ warnings: [
117
+ "Expo documentation search serves official-api sources only; the requested source kinds exclude it",
118
+ ],
119
+ };
98
120
  }
99
121
  try {
100
- const documents = await searchExpoAlgolia(sanitizedQuery, perProviderLimit);
122
+ const documents = await searchExpoAlgolia(sanitizedQuery, perProviderLimit, network.fetch);
101
123
  return {
102
124
  results: documents.map((document, position) => ({
103
125
  id: `expo-algolia:${document.url}`,
@@ -125,7 +147,7 @@ export async function createDocumentationServer(options = {}) {
125
147
  (!sourceKinds || sourceKinds.includes("official-guide")) &&
126
148
  !language) {
127
149
  try {
128
- const results = await searchOkHttpDocumentation(sanitizedQuery, perProviderLimit, options.fetchImplementation ?? fetch);
150
+ const results = await searchOkHttpDocumentation(sanitizedQuery, perProviderLimit, network.fetch);
129
151
  if (results.length > 0) {
130
152
  return { results, warnings: [] };
131
153
  }
@@ -146,9 +168,8 @@ export async function createDocumentationServer(options = {}) {
146
168
  try {
147
169
  return await searchRemoteDocumentation(provider, sanitizedQuery, perProviderLimit, {
148
170
  apiKey: options.braveApiKey,
149
- ...(options.fetchImplementation
150
- ? { fetchImplementation: options.fetchImplementation }
151
- : {}),
171
+ fetchImplementation: network.fetch,
172
+ deadline: network.signal,
152
173
  ...(language ? { language } : {}),
153
174
  ...(sourceKinds ? { sourceKinds } : {}),
154
175
  });
@@ -184,10 +205,13 @@ export async function createDocumentationServer(options = {}) {
184
205
  })
185
206
  .slice(0, boundedLimit);
186
207
  const uniqueWarnings = [...new Set(warnings)].slice(0, 10);
208
+ const network_ = network.counts();
187
209
  const payload = {
188
210
  notice: untrustedMaterialNotice,
189
211
  retrieval: {
190
212
  scopedWebSearch: Boolean(options.braveApiKey),
213
+ // What this single budget unit actually cost.
214
+ network: network_,
191
215
  expoSearch: selectedProviders.includes("expo"),
192
216
  localIndex: index
193
217
  ? {
@@ -199,7 +223,7 @@ export async function createDocumentationServer(options = {}) {
199
223
  ...(uniqueWarnings.length > 0 ? { warnings: uniqueWarnings } : {}),
200
224
  results,
201
225
  };
202
- await audit.complete(requestId, "search_platform_docs", auditInput, results, uniqueWarnings);
226
+ await audit.complete(requestId, "search_platform_docs", auditInput, results, uniqueWarnings, network_);
203
227
  return {
204
228
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
205
229
  };
@@ -232,6 +256,8 @@ export async function createDocumentationServer(options = {}) {
232
256
  .enum(DIRECT_DOCUMENT_CONTEXT_MODES)
233
257
  .default("section")
234
258
  .describe("Context breadth: focused=matched and adjacent passages, section=bounded contiguous window around the match, document=bounded extracted page text"),
259
+ // Focused passage count is a context-window control, deliberately
260
+ // independent of the search results-per-query bound.
235
261
  limit: z
236
262
  .number()
237
263
  .int()
@@ -247,28 +273,46 @@ export async function createDocumentationServer(options = {}) {
247
273
  openWorldHint: true,
248
274
  },
249
275
  }, async ({ url, provider, query, context, limit }) => {
250
- const sanitizedQuery = query ? sanitizeDocumentationQuery(query) : undefined;
251
- // Resolve and validate before recording anything. This prevents credentials,
252
- // query strings, or covert high-entropy path data from reaching either the
253
- // network or the append-only audit file.
254
- const target = resolveDirectDocumentationTarget(url, provider);
276
+ let sanitizedQuery;
277
+ try {
278
+ sanitizedQuery = query ? sanitizeDocumentationQuery(query) : undefined;
279
+ }
280
+ catch (error) {
281
+ await audit.rejected("fetch_platform_doc", "query-rejected");
282
+ throw error;
283
+ }
284
+ // Resolve and validate before recording the input. This prevents
285
+ // credentials, query strings, or covert high-entropy path data from
286
+ // reaching either the network or the append-only audit file; a refusal is
287
+ // still audited by reason class so unmet demand stays visible.
288
+ let target;
289
+ try {
290
+ target = resolveDirectDocumentationTarget(url, provider);
291
+ }
292
+ catch (error) {
293
+ await audit.rejected("fetch_platform_doc", "url-rejected");
294
+ throw error;
295
+ }
255
296
  const auditInput = {
297
+ platform: getProvider(target.provider).platform,
256
298
  providers: [target.provider],
257
299
  ...(sanitizedQuery ? { query: sanitizedQuery } : {}),
258
300
  url: target.url.href,
259
301
  context,
260
302
  };
261
303
  const requestId = await audit.reserve("fetch_platform_doc", auditInput);
304
+ // A single URL still costs up to six round trips through the redirect
305
+ // chain, so this call needs the same deadline and ledger as a search.
306
+ const network = createResearchNetwork(baseFetch, timeoutMs);
262
307
  try {
263
308
  const fetched = await fetchDocumentationUrl(target.url.href, {
264
309
  provider: target.provider,
265
310
  ...(sanitizedQuery ? { query: sanitizedQuery } : {}),
266
311
  context,
267
- limit: Math.min(limit, maxResultsPerCall),
268
- ...(options.fetchImplementation
269
- ? { fetchImplementation: options.fetchImplementation }
270
- : {}),
312
+ limit,
313
+ fetchImplementation: network.fetch,
271
314
  });
315
+ const network_ = network.counts();
272
316
  const payload = {
273
317
  notice: untrustedMaterialNotice,
274
318
  retrieval: {
@@ -277,6 +321,7 @@ export async function createDocumentationServer(options = {}) {
277
321
  sourceKind: fetched.sourceKind,
278
322
  canonicalUrl: fetched.canonicalUrl,
279
323
  context: fetched.context,
324
+ network: network_,
280
325
  },
281
326
  results: fetched.results,
282
327
  };
@@ -285,7 +330,7 @@ export async function createDocumentationServer(options = {}) {
285
330
  platform: getProvider(fetched.provider).platform,
286
331
  providers: [fetched.provider],
287
332
  url: fetched.canonicalUrl,
288
- }, fetched.results);
333
+ }, fetched.results, [], network_);
289
334
  return {
290
335
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
291
336
  };
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // @ref LLP 0013#one-package-two-binaries [implements] — the environment boundary the engine's MCP config cannot provide
3
+ /**
4
+ * Environment boundary for the bounded documentation MCP.
5
+ *
6
+ * ECR declares a minimal `env` in the engine's MCP configuration, but neither
7
+ * engine treats that as a replacement: Claude Code and OpenCode both MERGE it
8
+ * onto the environment the engine already holds. Verified by spawning each
9
+ * engine against a probe MCP that records variable names — the child saw the
10
+ * engine's model credential on both, and OpenCode additionally passed through
11
+ * the runner's whole ambient environment.
12
+ *
13
+ * So the boundary has to be ours. The engine spawns this wrapper; the wrapper
14
+ * spawns the real server with an environment it CONSTRUCTS. Whatever the engine
15
+ * merged in reaches this process and stops here.
16
+ *
17
+ * This file deliberately does almost nothing. It loads no HTML/JSON parser and
18
+ * opens no socket, because it is the one process in the chain that still holds
19
+ * the engine's credentials. Everything that touches untrusted remote content
20
+ * runs in the child, which never receives them.
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { constants } from "node:os";
25
+ import { fileURLToPath } from "node:url";
26
+ import { researchWrapperEnvironment } from "./child-env.js";
27
+ const builtEntry = fileURLToPath(new URL("./cli.js", import.meta.url));
28
+ const sourceEntry = fileURLToPath(new URL("./cli.ts", import.meta.url));
29
+ const child = spawn(
30
+ // The current interpreter by absolute path, never a PATH lookup: during a
31
+ // review the cwd is the untrusted PR-head tree.
32
+ process.execPath, [existsSync(builtEntry) ? builtEntry : sourceEntry, ...process.argv.slice(2)], {
33
+ env: researchWrapperEnvironment(process.env),
34
+ // The child owns the engine's stdio directly, so the wrapper never sits in
35
+ // the MCP byte stream and cannot truncate, buffer, or reorder a message.
36
+ stdio: "inherit",
37
+ });
38
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
39
+ for (const signal of FORWARDED_SIGNALS) {
40
+ // The engine signals the process it spawned — us. Pass it on, or the server
41
+ // outlives the review and keeps its audit lock.
42
+ process.on(signal, () => {
43
+ child.kill(signal);
44
+ });
45
+ }
46
+ child.on("error", (error) => {
47
+ process.stderr.write(`review-research-mcp: failed to start bounded server: ${error.message}\n`);
48
+ process.exitCode = 1;
49
+ });
50
+ child.on("exit", (code, signal) => {
51
+ // Report a signal death as the conventional 128+n rather than a silent 0, so a
52
+ // killed server is distinguishable from a clean shutdown.
53
+ process.exitCode = signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1);
54
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,15 +26,25 @@
26
26
 
27
27
  // Optional bounded platform research (ROOT-ONLY; off by default). Reviewer and
28
28
  // cross-file passes can call ECR's bundled MCP for exact API-symbol searches and
29
- // supported documentation URLs. The MCP sanitizes queries, uses fixed provider
30
- // allowlists, audits results, and never receives model credentials. BRAVE_SEARCH_API_KEY
31
- // enables fixed site-scoped discovery; Expo uses its public documentation search.
32
- // indexPath remains an optional offline fallback only.
29
+ // supported documentation URLs. The MCP uses fixed provider allowlists and audits
30
+ // every call. It runs behind a wrapper that rebuilds its environment from an
31
+ // explicit allowlist, because both engines MERGE the configured env onto their own
32
+ // rather than replacing it so the server sees the search key and these limits,
33
+ // and never the model credential. BRAVE_SEARCH_API_KEY enables fixed site-scoped
34
+ // discovery; Expo uses its public documentation search.
35
+ //
36
+ // Queries are shape-checked, not confidentiality-checked: the reviewing model
37
+ // chooses the outbound terms, so enable this only where repository-derived terms
38
+ // may be shared with Brave and the documentation providers.
39
+ //
40
+ // maxQueries bounds MCP CALLS, not requests — one search can issue a discovery
41
+ // request per provider plus a page fetch per candidate. timeoutMs is the MCP's own
42
+ // end-to-end deadline per call. indexPath remains an optional offline fallback.
33
43
  // "research": {
34
44
  // "enabled": true,
35
45
  // "maxQueries": 8,
36
46
  // "resultsPerQuery": 2,
37
- // "timeoutMs": 15000
47
+ // "timeoutMs": 30000
38
48
  // },
39
49
 
40
50
  // Large diffs are split into focused chunks by changed-line count, plus a