@expo/code-review-cli 0.12.4 → 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.
- package/README.md +34 -7
- package/build/config/load.js +1 -1
- package/build/core/research.js +36 -28
- package/build/research-mcp/audit.js +2 -1
- package/build/research-mcp/child-env.js +74 -0
- package/build/research-mcp/cli.js +1 -0
- package/build/research-mcp/network.js +75 -0
- package/build/research-mcp/remote-search.js +3 -1
- package/build/research-mcp/server.js +20 -10
- package/build/research-mcp/wrapper.js +54 -0
- package/package.json +1 -1
- package/templates/config.jsonc +14 -4
package/README.md
CHANGED
|
@@ -190,7 +190,7 @@ Enable it only in the root config, which CI loads from the PR's trusted base:
|
|
|
190
190
|
"enabled": true,
|
|
191
191
|
"maxQueries": 8,
|
|
192
192
|
"resultsPerQuery": 2,
|
|
193
|
-
"timeoutMs":
|
|
193
|
+
"timeoutMs": 30000
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
```
|
|
@@ -205,13 +205,26 @@ the current absolute Node executable, so a PR-owned `PATH` entry cannot replace
|
|
|
205
205
|
either component. Each review gets an owner-only temporary MCP config and append-only
|
|
206
206
|
audit. Claude receives that explicit config under `--strict-mcp-config`, with project
|
|
207
207
|
settings and slash commands disabled; OpenCode receives the same fixed local command.
|
|
208
|
-
The Brave credential is passed to the MCP child, not the model process.
|
|
209
208
|
|
|
210
|
-
|
|
209
|
+
That command is a wrapper, not the server. Both engines merge a configured MCP `env`
|
|
210
|
+
onto their own environment instead of replacing it, so the declared block alone cannot
|
|
211
|
+
bound the child — the engine's model credential reaches it, and OpenCode additionally
|
|
212
|
+
passes down the runner's whole ambient environment. The wrapper therefore rebuilds the
|
|
213
|
+
environment from an explicit allowlist before starting the server, and loads no parser
|
|
214
|
+
and opens no socket of its own. The server process sees the search key, the call
|
|
215
|
+
bounds, and locale/proxy settings; it never sees a model credential. The Brave
|
|
216
|
+
credential travels the same way and is never added to the model process env.
|
|
217
|
+
|
|
218
|
+
The MCP bounds the shape, host, and volume of outbound requests. It is not a
|
|
219
|
+
confidentiality boundary: the reviewing model chooses the query terms and URLs, and a
|
|
220
|
+
low-entropy identifier can carry repository-derived data past every check below.
|
|
221
|
+
Enable research only where repository-derived terms may be shared with Brave and the
|
|
222
|
+
documentation providers. Search queries are normalized before
|
|
211
223
|
logging or networking: quoted literals, URLs, email addresses, paths, prose stop
|
|
212
224
|
words, overlong/high-entropy tokens, and unsupported punctuation are removed;
|
|
213
|
-
credential-shaped or secret-labeled input fails closed. The remaining query must be
|
|
214
|
-
|
|
225
|
+
credential-shaped or secret-labeled input fails closed. The remaining query must be at
|
|
226
|
+
most eight short tokens and either contain an API-like symbol or be a short multi-word
|
|
227
|
+
lowercase concept phrase. Direct URLs must use plain
|
|
215
228
|
HTTPS with no credentials, port, query string, or fragment; suspicious/high-entropy
|
|
216
229
|
path segments fail closed. The fixed provider host/path allowlist and redirect,
|
|
217
230
|
response-size, content-type, and timeout checks still apply after that first gate.
|
|
@@ -219,6 +232,17 @@ These deterministic checks greatly reduce accidental exfiltration; they are not
|
|
|
219
232
|
proof that every low-entropy string is harmless, so reviewer prompts also forbid
|
|
220
233
|
sending repository text and the review-wide MCP budget defaults to eight calls.
|
|
221
234
|
|
|
235
|
+
`maxQueries` bounds MCP calls, not network requests. One search selects up to four
|
|
236
|
+
providers, and each issues its own discovery request plus a page fetch per candidate,
|
|
237
|
+
so eight calls can mean roughly thirty discovery requests and over a hundred page
|
|
238
|
+
downloads. Every call therefore reports its own ledger — discovery requests, page
|
|
239
|
+
fetches, redirect hops, total HTTP requests, and elapsed time — and the review log and
|
|
240
|
+
Actions summary report the totals. `timeoutMs` is the MCP's own end-to-end deadline for
|
|
241
|
+
one call, enforced by the server across discovery, redirects, retrieval, and
|
|
242
|
+
extraction; a call that hits it returns what it already has rather than failing. It has
|
|
243
|
+
to live there because OpenCode's `timeout` bounds only tool discovery and Claude
|
|
244
|
+
provides no per-call timeout at all.
|
|
245
|
+
|
|
222
246
|
For non-Expo providers, discovery sends a fixed, provider-owned `site:` scope plus
|
|
223
247
|
the bounded query to Brave's fixed Web Search endpoint. Search snippets and titles
|
|
224
248
|
are never treated as evidence. ECR independently rejects off-allowlist result URLs,
|
|
@@ -276,8 +300,11 @@ same audit trail in the step summary, while `.runs/reviews.jsonl` keeps the quer
|
|
|
276
300
|
plus bounded returned passages for short-lived operational inspection. Reviewers
|
|
277
301
|
are instructed to attach `sources` only when documentation materially supports a
|
|
278
302
|
finding. ECR accepts only exact URLs returned during that review, restores canonical
|
|
279
|
-
titles, carries citations through coordination, and renders them below the finding
|
|
280
|
-
|
|
303
|
+
titles, carries citations through coordination, and renders them below the finding.
|
|
304
|
+
A citation to a URL this review never retrieved is dropped outright. Relatedness is a
|
|
305
|
+
separate, weaker guarantee: a cited finding is escalated to the verifier with the
|
|
306
|
+
audited passage inline, which judges whether that passage actually supports the claim
|
|
307
|
+
and strips the citation when it does not.
|
|
281
308
|
|
|
282
309
|
Reviewers also emit a bounded `researchDecisions` record only when documentation
|
|
283
310
|
materially confirms a finding candidate or proves one safe. ECR grounds those records
|
package/build/config/load.js
CHANGED
|
@@ -31,7 +31,7 @@ const RESEARCH_CONFIG_DEFAULTS = {
|
|
|
31
31
|
enabled: false,
|
|
32
32
|
maxQueries: 8,
|
|
33
33
|
resultsPerQuery: 2,
|
|
34
|
-
timeoutMs:
|
|
34
|
+
timeoutMs: 30_000,
|
|
35
35
|
};
|
|
36
36
|
/** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
|
|
37
37
|
const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
|
package/build/core/research.js
CHANGED
|
@@ -7,6 +7,8 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { readResearchAudit } from "../research-mcp/audit.js";
|
|
10
|
+
import { researchChildEnvironment } from "../research-mcp/child-env.js";
|
|
11
|
+
import { totalResearchNetwork } from "../research-mcp/network.js";
|
|
10
12
|
export { OPENCODE_RESEARCH_TOOLS } from "./tools.js";
|
|
11
13
|
export const RESEARCH_DECISION_COUNT_LIMIT = 16;
|
|
12
14
|
export const RESEARCH_DECISION_BYTES_LIMIT = 20_000;
|
|
@@ -15,33 +17,17 @@ export const CLAUDE_RESEARCH_TOOLS = [
|
|
|
15
17
|
`mcp__${RESEARCH_MCP_SERVER_NAME}__search_platform_docs`,
|
|
16
18
|
`mcp__${RESEARCH_MCP_SERVER_NAME}__fetch_platform_doc`,
|
|
17
19
|
];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const RESEARCH_SEARCH_API_KEY = "BRAVE_SEARCH_API_KEY";
|
|
27
|
-
export function researchChildEnvironment(source = process.env) {
|
|
28
|
-
const environment = {
|
|
29
|
-
LANG: "C.UTF-8",
|
|
30
|
-
LC_ALL: "C.UTF-8",
|
|
31
|
-
...(process.platform === "win32" && source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}),
|
|
32
|
-
};
|
|
33
|
-
for (const key of RESEARCH_PROXY_ENV_KEYS) {
|
|
34
|
-
if (source[key])
|
|
35
|
-
environment[key] = source[key];
|
|
36
|
-
}
|
|
37
|
-
if (source[RESEARCH_SEARCH_API_KEY]) {
|
|
38
|
-
environment[RESEARCH_SEARCH_API_KEY] = source[RESEARCH_SEARCH_API_KEY];
|
|
39
|
-
}
|
|
40
|
-
return environment;
|
|
41
|
-
}
|
|
20
|
+
export { researchChildEnvironment };
|
|
21
|
+
/**
|
|
22
|
+
* The engine spawns the WRAPPER, not the server. Both Claude Code and OpenCode
|
|
23
|
+
* merge the config's `env` block onto their own environment instead of replacing
|
|
24
|
+
* it, so the block below cannot bound the child on its own; the wrapper rebuilds
|
|
25
|
+
* the environment from an explicit allowlist before the real server starts. See
|
|
26
|
+
* research-mcp/wrapper.ts.
|
|
27
|
+
*/
|
|
42
28
|
export function bundledResearchServer() {
|
|
43
|
-
const builtEntry = fileURLToPath(new URL("../research-mcp/
|
|
44
|
-
const sourceEntry = fileURLToPath(new URL("../research-mcp/
|
|
29
|
+
const builtEntry = fileURLToPath(new URL("../research-mcp/wrapper.js", import.meta.url));
|
|
30
|
+
const sourceEntry = fileURLToPath(new URL("../research-mcp/wrapper.ts", import.meta.url));
|
|
45
31
|
return {
|
|
46
32
|
command: process.execPath,
|
|
47
33
|
args: [existsSync(builtEntry) ? builtEntry : sourceEntry],
|
|
@@ -49,8 +35,12 @@ export function bundledResearchServer() {
|
|
|
49
35
|
}
|
|
50
36
|
/**
|
|
51
37
|
* Create one owner-only MCP configuration and append-only audit for a review run.
|
|
52
|
-
* The model process receives only the config path
|
|
53
|
-
*
|
|
38
|
+
* The model process receives only the config path.
|
|
39
|
+
*
|
|
40
|
+
* The `env` block below is a request the engine merges rather than applies — see
|
|
41
|
+
* bundledResearchServer. The wrapper it names is what actually bounds the server's
|
|
42
|
+
* environment, so the Brave credential and these limits are the only things the
|
|
43
|
+
* server can see, whatever the engine passed down.
|
|
54
44
|
*/
|
|
55
45
|
export async function createResearchMcpRuntime(config) {
|
|
56
46
|
if (!config.enabled)
|
|
@@ -70,6 +60,10 @@ export async function createResearchMcpRuntime(config) {
|
|
|
70
60
|
REVIEW_RESEARCH_AUDIT_PATH: auditPath,
|
|
71
61
|
REVIEW_RESEARCH_MAX_CALLS: String(config.maxQueries),
|
|
72
62
|
REVIEW_RESEARCH_MAX_RESULTS: String(config.resultsPerQuery),
|
|
63
|
+
// The MCP enforces this itself as a per-call deadline. OpenCode's `timeout`
|
|
64
|
+
// only bounds tool DISCOVERY and Claude has no equivalent, so neither engine
|
|
65
|
+
// can bound how long a call actually runs — the server has to.
|
|
66
|
+
REVIEW_RESEARCH_TIMEOUT_MS: String(config.timeoutMs),
|
|
73
67
|
}).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])));
|
|
74
68
|
await writeFile(claudeConfigPath, `${JSON.stringify({
|
|
75
69
|
mcpServers: {
|
|
@@ -131,6 +125,9 @@ export async function researchProvenanceFromAudit(auditPath) {
|
|
|
131
125
|
warnings: [...new Set(warnings)].slice(0, 10),
|
|
132
126
|
};
|
|
133
127
|
const provenance = toResearchProvenance(run);
|
|
128
|
+
const ledgers = records.flatMap((record) => (record.network ? [record.network] : []));
|
|
129
|
+
if (ledgers.length > 0)
|
|
130
|
+
provenance.network = totalResearchNetwork(ledgers);
|
|
134
131
|
if (rejections.length > 0) {
|
|
135
132
|
const counts = new Map();
|
|
136
133
|
for (const rejection of rejections) {
|
|
@@ -199,6 +196,11 @@ export function formatResearchProgress(provenance) {
|
|
|
199
196
|
lines.push(` result: ${cleanEvidenceText(result.title, 160)} (${result.provider}/${result.sourceKind}) — ${result.url}`);
|
|
200
197
|
}
|
|
201
198
|
}
|
|
199
|
+
if (provenance.network) {
|
|
200
|
+
const { searchRequests, documentRequests, redirects, totalRequests } = provenance.network;
|
|
201
|
+
lines.push(` research network: ${searchRequests} search request(s), ${documentRequests} page fetch(es), ` +
|
|
202
|
+
`${redirects} redirect(s) — ${totalRequests} HTTP request(s) total`);
|
|
203
|
+
}
|
|
202
204
|
for (const rejection of provenance.rejections ?? []) {
|
|
203
205
|
lines.push(` research: ${rejection.count} ${rejection.tool} call(s) rejected before execution (${rejection.reason})`);
|
|
204
206
|
}
|
|
@@ -242,6 +244,12 @@ export function renderResearchMarkdown(provenance) {
|
|
|
242
244
|
lines.push(` - [${escapeMarkdownLabel(result.title)}](<${result.url}>) — ${escapeMarkdownLabel(result.provider)}/${escapeMarkdownLabel(result.sourceKind)}`);
|
|
243
245
|
}
|
|
244
246
|
}
|
|
247
|
+
if (provenance.network) {
|
|
248
|
+
const { searchRequests, documentRequests, redirects, totalRequests, elapsedMs } = provenance.network;
|
|
249
|
+
lines.push("", `Outbound: **${searchRequests}** search request(s), **${documentRequests}** page fetch(es), ` +
|
|
250
|
+
`**${redirects}** redirect(s) — **${totalRequests}** HTTP request(s) across ` +
|
|
251
|
+
`${(elapsedMs / 1000).toFixed(1)}s of call time.`);
|
|
252
|
+
}
|
|
245
253
|
for (const rejection of provenance.rejections ?? []) {
|
|
246
254
|
lines.push(`- ⚠️ ${rejection.count} \`${escapeMarkdownLabel(rejection.tool)}\` call(s) rejected before execution (${escapeMarkdownLabel(rejection.reason)}).`);
|
|
247
255
|
}
|
|
@@ -129,7 +129,7 @@ export class ResearchAudit {
|
|
|
129
129
|
});
|
|
130
130
|
return requestId;
|
|
131
131
|
}
|
|
132
|
-
async complete(requestId, tool, input, results, warnings = []) {
|
|
132
|
+
async complete(requestId, tool, input, results, warnings = [], network) {
|
|
133
133
|
await this.append({
|
|
134
134
|
type: "completed",
|
|
135
135
|
requestId,
|
|
@@ -137,6 +137,7 @@ export class ResearchAudit {
|
|
|
137
137
|
input,
|
|
138
138
|
results: results.map(boundedResult),
|
|
139
139
|
warnings: warnings.slice(0, 10).map((warning) => warning.slice(0, 500)),
|
|
140
|
+
...(network ? { network } : {}),
|
|
140
141
|
timestamp: new Date().toISOString(),
|
|
141
142
|
});
|
|
142
143
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @ref LLP 0013#one-package-two-binaries [implements] — the bounded MCP's environment is constructed, never inherited
|
|
2
|
+
/**
|
|
3
|
+
* The single definition of what the bounded documentation MCP is allowed to see
|
|
4
|
+
* in its environment.
|
|
5
|
+
*
|
|
6
|
+
* Two callers share it, and they need it for different reasons:
|
|
7
|
+
*
|
|
8
|
+
* - `createResearchMcpRuntime` writes `researchChildEnvironment()` into the
|
|
9
|
+
* engine's MCP configuration. That block is a REQUEST, not a guarantee: both
|
|
10
|
+
* Claude Code and OpenCode merge it onto the environment the engine already
|
|
11
|
+
* has rather than replacing it, so the declared allowlist alone never bounds
|
|
12
|
+
* the child.
|
|
13
|
+
* - `wrapper.ts` applies `researchWrapperEnvironment()` when it spawns the real
|
|
14
|
+
* server. That IS the guarantee — the server process is handed a constructed
|
|
15
|
+
* environment, so whatever the engine merged in stops at the wrapper.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here imports a parser, a network client, or the config schema: the
|
|
18
|
+
* wrapper must stay loadable without pulling untrusted-content machinery into
|
|
19
|
+
* the one process that still holds the engine's credentials.
|
|
20
|
+
*/
|
|
21
|
+
const PROXY_ENV_KEYS = [
|
|
22
|
+
"HTTP_PROXY",
|
|
23
|
+
"HTTPS_PROXY",
|
|
24
|
+
"NO_PROXY",
|
|
25
|
+
"http_proxy",
|
|
26
|
+
"https_proxy",
|
|
27
|
+
"no_proxy",
|
|
28
|
+
];
|
|
29
|
+
export const RESEARCH_SEARCH_API_KEY = "BRAVE_SEARCH_API_KEY";
|
|
30
|
+
/**
|
|
31
|
+
* Bounds ECR sets on the child through the config env block. These are inputs to
|
|
32
|
+
* the server, so the wrapper must forward them; every one is a number, a path ECR
|
|
33
|
+
* itself chose, or both, and none is a credential.
|
|
34
|
+
*/
|
|
35
|
+
export const RESEARCH_RUNTIME_ENV_KEYS = [
|
|
36
|
+
"REVIEW_RESEARCH_AUDIT_PATH",
|
|
37
|
+
"REVIEW_RESEARCH_INDEX_PATH",
|
|
38
|
+
"REVIEW_RESEARCH_MAX_CALLS",
|
|
39
|
+
"REVIEW_RESEARCH_MAX_RESULTS",
|
|
40
|
+
"REVIEW_RESEARCH_TIMEOUT_MS",
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Locale, proxy configuration, and the search-only credential — the environment
|
|
44
|
+
* the MCP actually needs to do its job.
|
|
45
|
+
*
|
|
46
|
+
* NODE_OPTIONS is deliberately absent: it is arbitrary code injection into the
|
|
47
|
+
* process that parses untrusted remote documents. So are PATH and HOME — the
|
|
48
|
+
* wrapper spawns the server by absolute path and the server writes only to the
|
|
49
|
+
* audit path it is given.
|
|
50
|
+
*/
|
|
51
|
+
export function researchChildEnvironment(source = process.env) {
|
|
52
|
+
const environment = {
|
|
53
|
+
LANG: "C.UTF-8",
|
|
54
|
+
LC_ALL: "C.UTF-8",
|
|
55
|
+
...(process.platform === "win32" && source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}),
|
|
56
|
+
};
|
|
57
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
58
|
+
if (source[key])
|
|
59
|
+
environment[key] = source[key];
|
|
60
|
+
}
|
|
61
|
+
if (source[RESEARCH_SEARCH_API_KEY]) {
|
|
62
|
+
environment[RESEARCH_SEARCH_API_KEY] = source[RESEARCH_SEARCH_API_KEY];
|
|
63
|
+
}
|
|
64
|
+
return environment;
|
|
65
|
+
}
|
|
66
|
+
/** The child environment plus the bounds ECR passes through the config block. */
|
|
67
|
+
export function researchWrapperEnvironment(source = process.env) {
|
|
68
|
+
const environment = researchChildEnvironment(source);
|
|
69
|
+
for (const key of RESEARCH_RUNTIME_ENV_KEYS) {
|
|
70
|
+
if (source[key] !== undefined)
|
|
71
|
+
environment[key] = source[key];
|
|
72
|
+
}
|
|
73
|
+
return environment;
|
|
74
|
+
}
|
|
@@ -58,6 +58,7 @@ async function main() {
|
|
|
58
58
|
: {}),
|
|
59
59
|
maxCalls: boundedInteger("REVIEW_RESEARCH_MAX_CALLS", 8, 1, 20),
|
|
60
60
|
maxResultsPerCall: boundedInteger("REVIEW_RESEARCH_MAX_RESULTS", 3, 1, 3),
|
|
61
|
+
timeoutMs: boundedInteger("REVIEW_RESEARCH_TIMEOUT_MS", 30_000, 1_000, 60_000),
|
|
61
62
|
...(process.env.BRAVE_SEARCH_API_KEY
|
|
62
63
|
? { braveApiKey: process.env.BRAVE_SEARCH_API_KEY }
|
|
63
64
|
: {}),
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Discovery endpoints. Everything else is a documentation page or asset. */
|
|
2
|
+
const SEARCH_ENDPOINTS = [
|
|
3
|
+
"https://api.search.brave.com/",
|
|
4
|
+
"https://qex7pb7d46-dsn.algolia.net/",
|
|
5
|
+
"https://lysine.dev/okhttp/search/search_index.json",
|
|
6
|
+
];
|
|
7
|
+
export class ResearchDeadlineError extends Error {
|
|
8
|
+
constructor(timeoutMs) {
|
|
9
|
+
super(`Documentation research call exceeded its ${timeoutMs}ms deadline`);
|
|
10
|
+
this.name = "ResearchDeadlineError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function createResearchNetwork(base, timeoutMs) {
|
|
14
|
+
const startedAt = Date.now();
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const timer = setTimeout(() => controller.abort(new ResearchDeadlineError(timeoutMs)), timeoutMs);
|
|
17
|
+
// The MCP is a short-lived stdio process; never hold the loop open for this.
|
|
18
|
+
timer.unref?.();
|
|
19
|
+
let searchRequests = 0;
|
|
20
|
+
let documentRequests = 0;
|
|
21
|
+
let redirects = 0;
|
|
22
|
+
let totalRequests = 0;
|
|
23
|
+
const wrapped = async (input, init) => {
|
|
24
|
+
if (controller.signal.aborted)
|
|
25
|
+
throw new ResearchDeadlineError(timeoutMs);
|
|
26
|
+
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
27
|
+
const isSearch = SEARCH_ENDPOINTS.some((endpoint) => href.startsWith(endpoint));
|
|
28
|
+
totalRequests++;
|
|
29
|
+
if (isSearch)
|
|
30
|
+
searchRequests++;
|
|
31
|
+
else
|
|
32
|
+
documentRequests++;
|
|
33
|
+
// Keep the caller's own per-attempt timeout AND add the call deadline, so a
|
|
34
|
+
// single slow hop still fails fast while the whole call stays bounded.
|
|
35
|
+
const signal = init?.signal
|
|
36
|
+
? AbortSignal.any([init.signal, controller.signal])
|
|
37
|
+
: controller.signal;
|
|
38
|
+
const response = await base(input, { ...init, signal });
|
|
39
|
+
if (response.status >= 300 && response.status < 400) {
|
|
40
|
+
redirects++;
|
|
41
|
+
// A redirect hop is a round trip, not a distinct resource, so undo the
|
|
42
|
+
// classification count. It has to be the SAME counter that was
|
|
43
|
+
// incremented: the search backends use `redirect: "manual"` too and treat
|
|
44
|
+
// a 3xx as an error, so unconditionally decrementing documentRequests
|
|
45
|
+
// drives it negative the first time a discovery endpoint redirects.
|
|
46
|
+
if (isSearch)
|
|
47
|
+
searchRequests--;
|
|
48
|
+
else
|
|
49
|
+
documentRequests--;
|
|
50
|
+
}
|
|
51
|
+
return response;
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
fetch: wrapped,
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
expired: () => controller.signal.aborted,
|
|
57
|
+
counts: () => ({
|
|
58
|
+
searchRequests,
|
|
59
|
+
documentRequests,
|
|
60
|
+
redirects,
|
|
61
|
+
totalRequests,
|
|
62
|
+
elapsedMs: Date.now() - startedAt,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Sum per-call ledgers into one review-wide total. */
|
|
67
|
+
export function totalResearchNetwork(counts) {
|
|
68
|
+
return counts.reduce((total, entry) => ({
|
|
69
|
+
searchRequests: total.searchRequests + entry.searchRequests,
|
|
70
|
+
documentRequests: total.documentRequests + entry.documentRequests,
|
|
71
|
+
redirects: total.redirects + entry.redirects,
|
|
72
|
+
totalRequests: total.totalRequests + entry.totalRequests,
|
|
73
|
+
elapsedMs: total.elapsedMs + entry.elapsedMs,
|
|
74
|
+
}), { searchRequests: 0, documentRequests: 0, redirects: 0, totalRequests: 0, elapsedMs: 0 });
|
|
75
|
+
}
|
|
@@ -175,7 +175,9 @@ export async function searchRemoteDocumentation(providerId, query, limit, option
|
|
|
175
175
|
// downloading every discovery candidate.
|
|
176
176
|
const fetched = [];
|
|
177
177
|
let candidateIndex = 0;
|
|
178
|
-
while (fetched.length < limit &&
|
|
178
|
+
while (fetched.length < limit &&
|
|
179
|
+
candidateIndex < candidates.length &&
|
|
180
|
+
!options.deadline?.aborted) {
|
|
179
181
|
const outstanding = limit - fetched.length;
|
|
180
182
|
const batch = candidates.slice(candidateIndex, candidateIndex + outstanding);
|
|
181
183
|
candidateIndex += batch.length;
|
|
@@ -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",
|
|
@@ -89,6 +92,8 @@ export async function createDocumentationServer(options = {}) {
|
|
|
89
92
|
query: sanitizedQuery,
|
|
90
93
|
};
|
|
91
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);
|
|
92
97
|
try {
|
|
93
98
|
const localResults = index
|
|
94
99
|
? searchDocumentation(index, sanitizedQuery, {
|
|
@@ -114,7 +119,7 @@ export async function createDocumentationServer(options = {}) {
|
|
|
114
119
|
};
|
|
115
120
|
}
|
|
116
121
|
try {
|
|
117
|
-
const documents = await searchExpoAlgolia(sanitizedQuery, perProviderLimit,
|
|
122
|
+
const documents = await searchExpoAlgolia(sanitizedQuery, perProviderLimit, network.fetch);
|
|
118
123
|
return {
|
|
119
124
|
results: documents.map((document, position) => ({
|
|
120
125
|
id: `expo-algolia:${document.url}`,
|
|
@@ -142,7 +147,7 @@ export async function createDocumentationServer(options = {}) {
|
|
|
142
147
|
(!sourceKinds || sourceKinds.includes("official-guide")) &&
|
|
143
148
|
!language) {
|
|
144
149
|
try {
|
|
145
|
-
const results = await searchOkHttpDocumentation(sanitizedQuery, perProviderLimit,
|
|
150
|
+
const results = await searchOkHttpDocumentation(sanitizedQuery, perProviderLimit, network.fetch);
|
|
146
151
|
if (results.length > 0) {
|
|
147
152
|
return { results, warnings: [] };
|
|
148
153
|
}
|
|
@@ -163,9 +168,8 @@ export async function createDocumentationServer(options = {}) {
|
|
|
163
168
|
try {
|
|
164
169
|
return await searchRemoteDocumentation(provider, sanitizedQuery, perProviderLimit, {
|
|
165
170
|
apiKey: options.braveApiKey,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
: {}),
|
|
171
|
+
fetchImplementation: network.fetch,
|
|
172
|
+
deadline: network.signal,
|
|
169
173
|
...(language ? { language } : {}),
|
|
170
174
|
...(sourceKinds ? { sourceKinds } : {}),
|
|
171
175
|
});
|
|
@@ -201,10 +205,13 @@ export async function createDocumentationServer(options = {}) {
|
|
|
201
205
|
})
|
|
202
206
|
.slice(0, boundedLimit);
|
|
203
207
|
const uniqueWarnings = [...new Set(warnings)].slice(0, 10);
|
|
208
|
+
const network_ = network.counts();
|
|
204
209
|
const payload = {
|
|
205
210
|
notice: untrustedMaterialNotice,
|
|
206
211
|
retrieval: {
|
|
207
212
|
scopedWebSearch: Boolean(options.braveApiKey),
|
|
213
|
+
// What this single budget unit actually cost.
|
|
214
|
+
network: network_,
|
|
208
215
|
expoSearch: selectedProviders.includes("expo"),
|
|
209
216
|
localIndex: index
|
|
210
217
|
? {
|
|
@@ -216,7 +223,7 @@ export async function createDocumentationServer(options = {}) {
|
|
|
216
223
|
...(uniqueWarnings.length > 0 ? { warnings: uniqueWarnings } : {}),
|
|
217
224
|
results,
|
|
218
225
|
};
|
|
219
|
-
await audit.complete(requestId, "search_platform_docs", auditInput, results, uniqueWarnings);
|
|
226
|
+
await audit.complete(requestId, "search_platform_docs", auditInput, results, uniqueWarnings, network_);
|
|
220
227
|
return {
|
|
221
228
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
222
229
|
};
|
|
@@ -294,16 +301,18 @@ export async function createDocumentationServer(options = {}) {
|
|
|
294
301
|
context,
|
|
295
302
|
};
|
|
296
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);
|
|
297
307
|
try {
|
|
298
308
|
const fetched = await fetchDocumentationUrl(target.url.href, {
|
|
299
309
|
provider: target.provider,
|
|
300
310
|
...(sanitizedQuery ? { query: sanitizedQuery } : {}),
|
|
301
311
|
context,
|
|
302
312
|
limit,
|
|
303
|
-
|
|
304
|
-
? { fetchImplementation: options.fetchImplementation }
|
|
305
|
-
: {}),
|
|
313
|
+
fetchImplementation: network.fetch,
|
|
306
314
|
});
|
|
315
|
+
const network_ = network.counts();
|
|
307
316
|
const payload = {
|
|
308
317
|
notice: untrustedMaterialNotice,
|
|
309
318
|
retrieval: {
|
|
@@ -312,6 +321,7 @@ export async function createDocumentationServer(options = {}) {
|
|
|
312
321
|
sourceKind: fetched.sourceKind,
|
|
313
322
|
canonicalUrl: fetched.canonicalUrl,
|
|
314
323
|
context: fetched.context,
|
|
324
|
+
network: network_,
|
|
315
325
|
},
|
|
316
326
|
results: fetched.results,
|
|
317
327
|
};
|
|
@@ -320,7 +330,7 @@ export async function createDocumentationServer(options = {}) {
|
|
|
320
330
|
platform: getProvider(fetched.provider).platform,
|
|
321
331
|
providers: [fetched.provider],
|
|
322
332
|
url: fetched.canonicalUrl,
|
|
323
|
-
}, fetched.results);
|
|
333
|
+
}, fetched.results, [], network_);
|
|
324
334
|
return {
|
|
325
335
|
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
326
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
package/templates/config.jsonc
CHANGED
|
@@ -26,10 +26,20 @@
|
|
|
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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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,
|