@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.
- package/README.md +34 -7
- package/build/config/load.js +1 -1
- package/build/config/schema.js +5 -2
- package/build/core/prompts.js +22 -25
- package/build/core/research.js +114 -508
- package/build/core/review.js +23 -4
- package/build/core/schema.js +6 -0
- package/build/core/verify.js +44 -8
- package/build/research-mcp/audit.js +36 -3
- package/build/research-mcp/child-env.js +74 -0
- package/build/research-mcp/cli.js +1 -0
- package/build/research-mcp/expo-algolia.js +4 -4
- package/build/research-mcp/network.js +75 -0
- package/build/research-mcp/query-sanitizer.js +16 -3
- package/build/research-mcp/remote-search.js +18 -6
- package/build/research-mcp/server.js +64 -19
- package/build/research-mcp/wrapper.js +54 -0
- package/package.json +1 -1
- package/templates/config.jsonc +15 -5
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
268
|
-
|
|
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
package/templates/config.jsonc
CHANGED
|
@@ -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
|
|
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,
|
|
36
46
|
// "resultsPerQuery": 2,
|
|
37
|
-
// "timeoutMs":
|
|
47
|
+
// "timeoutMs": 30000
|
|
38
48
|
// },
|
|
39
49
|
|
|
40
50
|
// Large diffs are split into focused chunks by changed-line count, plus a
|