Skip to content

Commit 07ff5f0

Browse files
committed
Add Context Engine MCP integration
Introduce optional Context Engine MCP support for repository-aware reviews. Adds new env/inputs (CONTEXT_ENGINE_API_KEY / context_engine_api_key, CONTEXT_ENGINE_MCP_URL / context_engine_mcp_url, CONTEXT_ENGINE_COLLECTION / context_engine_collection, CONTEXT_ENGINE_TOOLS / context_engine_tools, CONTEXT_ENGINE_MAX_TOOLS / context_engine_max_tools), documents usage and defaults in README, and exposes the flags in action.yml. Implements a new context_engine_mcp module and wires MCP tool exposure into review prompts (per-batch tool scoping), with graceful fallback to diff-only mode on failures. Includes tests for CLI, context engine integration and prompts, and updates built artifacts (dist) and related source files.
1 parent dc004db commit 07ff5f0

18 files changed

Lines changed: 1108 additions & 312 deletions

README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,110 @@ Common optional settings:
8080
- `ALLOW_TITLE_UPDATE`: set to `true` to allow title rewriting when the PR title explicitly asks for it.
8181
- `GITHUB_API_URL`: GitHub Enterprise API URL.
8282
- `GITHUB_SERVER_URL`: GitHub Enterprise web URL.
83+
- `CONTEXT_ENGINE_API_KEY` or `CTXCE_API_KEY`: optional Context Engine API key. When set, review prompts can call Context Engine MCP tools for repository context.
84+
- `CONTEXT_ENGINE_MCP_URL` or `CTXCE_INDEXER_URL`: optional Context Engine MCP indexer URL. Defaults to `https://dev.context-engine.ai/indexer/mcp`.
85+
- `CONTEXT_ENGINE_COLLECTION`: optional collection name to scope MCP searches.
86+
- `CONTEXT_ENGINE_TOOLS`: optional comma-separated MCP tool allow-list. Defaults to `repo_search,batch_search,symbol_graph,batch_symbol_graph,graph_query,batch_graph_query,search_tests_for,search_config_for,search_commits_for`.
87+
- `CONTEXT_ENGINE_MAX_TOOLS`: maximum MCP tools exposed to reviewer LLMs. Defaults to `9`.
8388

8489
The action input names mirror the environment variables where applicable, for example `custom_mode`, `llm_model`, `llm_provider`, `github_api_url`, and `github_server_url`.
8590

91+
### Optional Context Engine MCP access
92+
93+
By default, the reviewer works from PR diffs only. This is the free/default mode and does not make Context Engine network calls.
94+
95+
Customers who want repository-aware review can opt in by providing a Context Engine API key. When enabled, the reviewer exposes a small allow-list of explicit Context Engine MCP tools to the LLM during review inference. If MCP setup or tool discovery fails, the reviewer logs a warning and falls back to the normal diff-only path for that run.
96+
97+
The default SaaS MCP endpoint is:
98+
99+
```text
100+
https://dev.context-engine.ai/indexer/mcp
101+
```
102+
103+
The memory MCP endpoint is not used by this reviewer.
104+
105+
#### GitHub Action usage
106+
107+
You can enable Context Engine either with action inputs or environment variables. Supplying the API key is the opt-in switch; no separate boolean flag is required.
108+
109+
Using environment variables:
110+
111+
```yaml
112+
env:
113+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
114+
LLM_PROVIDER: ai-sdk
115+
LLM_MODEL: gpt-5-mini
116+
LLM_API_KEY: ${{ secrets.OPENAI_API_KEY }}
117+
CONTEXT_ENGINE_API_KEY: ${{ secrets.CONTEXT_ENGINE_API_KEY }}
118+
CONTEXT_ENGINE_COLLECTION: your-indexed-collection
119+
```
120+
121+
Using action inputs:
122+
123+
```yaml
124+
with:
125+
github_token: ${{ secrets.GITHUB_TOKEN }}
126+
llm_provider: ai-sdk
127+
llm_model: gpt-5-mini
128+
context_engine_api_key: ${{ secrets.CONTEXT_ENGINE_API_KEY }}
129+
context_engine_collection: your-indexed-collection
130+
```
131+
132+
Optional action inputs:
133+
134+
- `context_engine_mcp_url`: defaults to `https://dev.context-engine.ai/indexer/mcp`.
135+
- `context_engine_tools`: comma-separated MCP tool allow-list.
136+
- `context_engine_max_tools`: maximum number of MCP tools exposed to the reviewer LLM. Defaults to `9`.
137+
138+
#### Default tool allow-list
139+
140+
The default allow-list intentionally exposes direct code/navigation tools only:
141+
142+
- `repo_search`
143+
- `batch_search`
144+
- `symbol_graph`
145+
- `batch_symbol_graph`
146+
- `graph_query`
147+
- `batch_graph_query`
148+
- `search_tests_for`
149+
- `search_config_for`
150+
- `search_commits_for`
151+
152+
The unified `search` router is excluded by default because it can be noisy for an autonomous reviewer. Memory tools are also excluded by default and the reviewer does not connect to the memory MCP endpoint. `search_commits_for` is included so the reviewer can inspect relevant commit history or historically co-changing files when that materially improves review quality.
153+
154+
You can override the allow-list if needed:
155+
156+
```yaml
157+
env:
158+
CONTEXT_ENGINE_TOOLS: repo_search,batch_search,symbol_graph,search_tests_for
159+
```
160+
161+
#### Review batching behavior
162+
163+
Context Engine tools are available only during PR review prompts, not during the PR summary prompt. The reviewer still uses the normal PR diff batching strategy:
164+
165+
1. Split changed files into review batches using `REVIEW_MAX_REVIEW_CHARS`.
166+
2. Invoke the review prompt once per batch.
167+
3. Expose Context Engine tools independently inside each batch inference.
168+
169+
This avoids prefetching repository context for the whole PR and keeps tool calls scoped to the batch being reviewed.
170+
171+
#### Local dry-run debugging
172+
173+
For local dry-run debugging, set the same variables in `.env` or pass them directly to the CLI. The dry-run path still uses the normal PR diff batching loop, so Context Engine tools are available independently to each review batch instead of prefetching repository context for the whole PR.
174+
175+
```bash
176+
npm run review -- --pr 123 --dry-run \
177+
--context-engine-api-key "$CONTEXT_ENGINE_API_KEY" \
178+
--context-engine-collection your-indexed-collection
179+
```
180+
181+
Optional dry-run flags:
182+
183+
- `--context-engine-mcp-url` / `--ce-url`
184+
- `--context-engine-tools` / `--ce-tools`
185+
- `--context-engine-max-tools` / `--ce-max-tools`
186+
86187
## Providers
87188

88189
The reviewer uses the AI SDK provider surface. Direct API providers use `LLM_API_KEY`, Z.AI can use `ZAI_API_KEY`, and AWS Bedrock can use AWS credentials instead.

action.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@ inputs:
3838
required: false
3939
description: 'Z.AI OpenAI-compatible base URL for glm-* models'
4040
default: 'https://api.z.ai/api/coding/paas/v4/'
41+
context_engine_api_key:
42+
required: false
43+
description: 'Optional Context Engine API key. When set, reviewer LLMs can call Context Engine MCP tools for repository context.'
44+
default: ''
45+
context_engine_mcp_url:
46+
required: false
47+
description: 'Optional Context Engine MCP indexer URL.'
48+
default: 'https://dev.context-engine.ai/indexer/mcp'
49+
context_engine_collection:
50+
required: false
51+
description: 'Optional Context Engine collection name to scope reviewer MCP searches.'
52+
default: ''
53+
context_engine_tools:
54+
required: false
55+
description: 'Optional comma-separated Context Engine MCP tools exposed to the reviewer LLM.'
56+
default: 'repo_search,batch_search,symbol_graph,batch_symbol_graph,graph_query,batch_graph_query,search_tests_for,search_config_for,search_commits_for'
57+
context_engine_max_tools:
58+
required: false
59+
description: 'Maximum number of Context Engine MCP tools exposed to the reviewer LLM.'
60+
default: '9'
4161

4262
review_scopes:
4363
required: false

dist/cli.js

Lines changed: 163 additions & 155 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/main.js

Lines changed: 160 additions & 152 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/cli.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { applyContextEngineCliOptions, parseArgs } from '../cli';
2+
3+
describe('dry-run CLI Context Engine options', () => {
4+
beforeEach(() => {
5+
delete process.env.CONTEXT_ENGINE_API_KEY;
6+
delete process.env.CONTEXT_ENGINE_MCP_URL;
7+
delete process.env.CONTEXT_ENGINE_COLLECTION;
8+
delete process.env.CONTEXT_ENGINE_TOOLS;
9+
delete process.env.CONTEXT_ENGINE_MAX_TOOLS;
10+
});
11+
12+
test('parses and applies Context Engine dry-run flags', () => {
13+
const args = parseArgs([
14+
'--pr', '42',
15+
'--dry-run',
16+
'--context-engine-api-key', 'ce-key',
17+
'--context-engine-mcp-url', 'https://dev.context-engine.ai/indexer/mcp',
18+
'--context-engine-collection', 'repo-col',
19+
'--context-engine-tools', 'repo_search,batch_search',
20+
'--context-engine-max-tools', '2',
21+
]);
22+
23+
expect(args.pr).toBe(42);
24+
expect(args.dryRun).toBe(true);
25+
26+
applyContextEngineCliOptions(args);
27+
28+
expect(process.env.CONTEXT_ENGINE_API_KEY).toBe('ce-key');
29+
expect(process.env.CONTEXT_ENGINE_MCP_URL).toBe('https://dev.context-engine.ai/indexer/mcp');
30+
expect(process.env.CONTEXT_ENGINE_COLLECTION).toBe('repo-col');
31+
expect(process.env.CONTEXT_ENGINE_TOOLS).toBe('repo_search,batch_search');
32+
expect(process.env.CONTEXT_ENGINE_MAX_TOOLS).toBe('2');
33+
});
34+
});

src/__tests__/config.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,25 @@ describe('Config', () => {
127127
expect(config.githubServerUrl).toBe('https://github.example.com');
128128
});
129129

130+
test('loads optional Context Engine MCP configuration from environment', () => {
131+
process.env.GITHUB_TOKEN = 'test-token';
132+
process.env.LLM_API_KEY = 'test-api-key';
133+
process.env.LLM_MODEL = 'test-model';
134+
process.env.CONTEXT_ENGINE_API_KEY = 'ce-key';
135+
process.env.CONTEXT_ENGINE_MCP_URL = 'https://dev.context-engine.ai/indexer/mcp';
136+
process.env.CONTEXT_ENGINE_COLLECTION = 'repo-collection';
137+
process.env.CONTEXT_ENGINE_TOOLS = 'repo_search,batch_search';
138+
process.env.CONTEXT_ENGINE_MAX_TOOLS = '2';
139+
140+
const config = new Config();
141+
142+
expect(config.contextEngineApiKey).toBe('ce-key');
143+
expect(config.contextEngineMcpUrl).toBe('https://dev.context-engine.ai/indexer/mcp');
144+
expect(config.contextEngineCollection).toBe('repo-collection');
145+
expect(config.contextEngineTools).toEqual(['repo_search', 'batch_search']);
146+
expect(config.contextEngineMaxTools).toBe(2);
147+
});
148+
130149
// test('skips loading inputs when DEBUG is set', () => {
131150
// process.env.GITHUB_TOKEN = 'test-token';
132151
// process.env.LLM_API_KEY = 'test-api-key';
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import config from '../config';
2+
import { ContextEngineMcpClient, appendContextEngineToolInstructions, createContextEngineTools } from '../context_engine_mcp';
3+
4+
jest.mock('@actions/core', () => ({
5+
info: jest.fn(),
6+
warning: jest.fn(),
7+
getInput: jest.fn(() => ''),
8+
getMultilineInput: jest.fn(() => []),
9+
}));
10+
11+
describe('Context Engine MCP integration', () => {
12+
const originalFetch = global.fetch;
13+
14+
beforeEach(() => {
15+
jest.resetAllMocks();
16+
(config as any).contextEngineApiKey = 'ce-key';
17+
(config as any).contextEngineMcpUrl = 'https://dev.context-engine.ai/indexer/mcp';
18+
(config as any).contextEngineCollection = 'repo-collection';
19+
(config as any).contextEngineTools = ['repo_search', 'batch_search'];
20+
(config as any).contextEngineMaxTools = 9;
21+
});
22+
23+
afterEach(() => {
24+
global.fetch = originalFetch;
25+
});
26+
27+
function jsonResponse(body: unknown, headers?: Record<string, string>): Response {
28+
return new Response(JSON.stringify(body), { status: 200, headers });
29+
}
30+
31+
test('lists remote MCP tools with bearer auth and session reuse', async () => {
32+
const fetchMock = jest.fn()
33+
.mockResolvedValueOnce(jsonResponse({ result: {} }, { 'mcp-session-id': 'session-1' }))
34+
.mockResolvedValueOnce(new Response('', { status: 202 }))
35+
.mockResolvedValueOnce(jsonResponse({ result: { tools: [{ name: 'search', description: 'Search code' }] } }));
36+
global.fetch = fetchMock as any;
37+
38+
const client = new ContextEngineMcpClient({
39+
url: 'https://dev.context-engine.ai/indexer/mcp',
40+
apiKey: 'ce-key',
41+
collection: 'repo-collection',
42+
});
43+
44+
const tools = await client.listTools();
45+
46+
expect(tools).toEqual([{ name: 'search', description: 'Search code' }]);
47+
expect(fetchMock).toHaveBeenCalledTimes(3);
48+
expect(fetchMock.mock.calls[0][1].headers.authorization).toBe('Bearer ce-key');
49+
expect(fetchMock.mock.calls[0][1].headers['x-collection']).toBe('repo-collection');
50+
expect(fetchMock.mock.calls[2][1].headers['mcp-session-id']).toBe('session-1');
51+
});
52+
53+
test('tool calls add reviewer-safe compact defaults and collection', async () => {
54+
const fetchMock = jest.fn()
55+
.mockResolvedValueOnce(jsonResponse({ result: {} }, { 'mcp-session-id': 'session-1' }))
56+
.mockResolvedValueOnce(new Response('', { status: 202 }))
57+
.mockResolvedValueOnce(jsonResponse({ result: { content: [{ type: 'text', text: 'ok' }] } }));
58+
global.fetch = fetchMock as any;
59+
60+
const client = new ContextEngineMcpClient({
61+
url: 'https://dev.context-engine.ai/indexer/mcp',
62+
apiKey: 'ce-key',
63+
collection: 'repo-collection',
64+
});
65+
66+
await client.callTool('repo_search', { query: 'authentication' });
67+
68+
const callBody = JSON.parse(fetchMock.mock.calls[2][1].body);
69+
expect(callBody.method).toBe('tools/call');
70+
expect(callBody.params.name).toBe('repo_search');
71+
expect(callBody.params.arguments).toEqual(expect.objectContaining({
72+
query: 'authentication',
73+
collection: 'repo-collection',
74+
limit: 5,
75+
compact: true,
76+
include_snippet: true,
77+
output_format: 'toon',
78+
}));
79+
});
80+
81+
test('creates AI SDK tools from the configured remote MCP allow-list', async () => {
82+
const fetchMock = jest.fn()
83+
.mockResolvedValueOnce(jsonResponse({ result: {} }, { 'mcp-session-id': 'session-1' }))
84+
.mockResolvedValueOnce(new Response('', { status: 202 }))
85+
.mockResolvedValueOnce(jsonResponse({ result: { tools: [
86+
{ name: 'search', description: 'Noisy router', inputSchema: { type: 'object' } },
87+
{ name: 'repo_search', description: 'Search code', inputSchema: { type: 'object' } },
88+
{ name: 'batch_search', description: 'Batch search code', inputSchema: { type: 'object' } },
89+
{ name: 'memory_store', description: 'Do not expose by default', inputSchema: { type: 'object' } },
90+
] } }));
91+
global.fetch = fetchMock as any;
92+
93+
const tools = await createContextEngineTools();
94+
95+
expect(Object.keys(tools || {})).toEqual(['repo_search', 'batch_search']);
96+
});
97+
98+
test('default allow-list excludes unified search router and memory tools', async () => {
99+
(config as any).contextEngineTools = [];
100+
const fetchMock = jest.fn()
101+
.mockResolvedValueOnce(jsonResponse({ result: {} }, { 'mcp-session-id': 'session-1' }))
102+
.mockResolvedValueOnce(new Response('', { status: 202 }))
103+
.mockResolvedValueOnce(jsonResponse({ result: { tools: [
104+
{ name: 'search', description: 'Noisy router', inputSchema: { type: 'object' } },
105+
{ name: 'repo_search', description: 'Repo search', inputSchema: { type: 'object' } },
106+
{ name: 'batch_search', description: 'Batch repo search', inputSchema: { type: 'object' } },
107+
{ name: 'symbol_graph', description: 'Symbol graph', inputSchema: { type: 'object' } },
108+
{ name: 'batch_symbol_graph', description: 'Batch symbol graph', inputSchema: { type: 'object' } },
109+
{ name: 'graph_query', description: 'Graph query', inputSchema: { type: 'object' } },
110+
{ name: 'batch_graph_query', description: 'Batch graph query', inputSchema: { type: 'object' } },
111+
{ name: 'search_tests_for', description: 'Tests', inputSchema: { type: 'object' } },
112+
{ name: 'search_config_for', description: 'Config', inputSchema: { type: 'object' } },
113+
{ name: 'search_commits_for', description: 'Git history', inputSchema: { type: 'object' } },
114+
{ name: 'memory_find', description: 'Memory', inputSchema: { type: 'object' } },
115+
] } }));
116+
global.fetch = fetchMock as any;
117+
118+
const tools = await createContextEngineTools();
119+
120+
expect(Object.keys(tools || {})).toEqual([
121+
'repo_search',
122+
'batch_search',
123+
'symbol_graph',
124+
'batch_symbol_graph',
125+
'graph_query',
126+
'batch_graph_query',
127+
'search_tests_for',
128+
'search_config_for',
129+
'search_commits_for',
130+
]);
131+
});
132+
133+
test('does not add snippet defaults to graph tools', async () => {
134+
const fetchMock = jest.fn()
135+
.mockResolvedValueOnce(jsonResponse({ result: {} }, { 'mcp-session-id': 'session-1' }))
136+
.mockResolvedValueOnce(new Response('', { status: 202 }))
137+
.mockResolvedValueOnce(jsonResponse({ result: { content: [{ type: 'text', text: 'ok' }] } }));
138+
global.fetch = fetchMock as any;
139+
140+
const client = new ContextEngineMcpClient({
141+
url: 'https://dev.context-engine.ai/indexer/mcp',
142+
apiKey: 'ce-key',
143+
collection: 'repo-collection',
144+
});
145+
146+
await client.callTool('symbol_graph', { symbol: 'authenticate', query_type: 'callers' });
147+
148+
const callBody = JSON.parse(fetchMock.mock.calls[2][1].body);
149+
expect(callBody.params.arguments).toEqual(expect.objectContaining({
150+
symbol: 'authenticate',
151+
query_type: 'callers',
152+
collection: 'repo-collection',
153+
limit: 5,
154+
output_format: 'toon',
155+
}));
156+
expect(callBody.params.arguments).not.toHaveProperty('compact');
157+
expect(callBody.params.arguments).not.toHaveProperty('include_snippet');
158+
});
159+
160+
test('does not add output-format defaults to git history tools', async () => {
161+
const fetchMock = jest.fn()
162+
.mockResolvedValueOnce(jsonResponse({ result: {} }, { 'mcp-session-id': 'session-1' }))
163+
.mockResolvedValueOnce(new Response('', { status: 202 }))
164+
.mockResolvedValueOnce(jsonResponse({ result: { content: [{ type: 'text', text: 'ok' }] } }));
165+
global.fetch = fetchMock as any;
166+
167+
const client = new ContextEngineMcpClient({
168+
url: 'https://dev.context-engine.ai/indexer/mcp',
169+
apiKey: 'ce-key',
170+
collection: 'repo-collection',
171+
});
172+
173+
await client.callTool('search_commits_for', { query: 'authentication bug' });
174+
175+
const callBody = JSON.parse(fetchMock.mock.calls[2][1].body);
176+
expect(callBody.params.arguments).toEqual(expect.objectContaining({
177+
query: 'authentication bug',
178+
collection: 'repo-collection',
179+
limit: 5,
180+
}));
181+
expect(callBody.params.arguments).not.toHaveProperty('output_format');
182+
expect(callBody.params.arguments).not.toHaveProperty('include_snippet');
183+
});
184+
185+
test('does not alter system prompt when Context Engine is not configured', () => {
186+
(config as any).contextEngineApiKey = undefined;
187+
expect(appendContextEngineToolInstructions('sys')).toBe('sys');
188+
});
189+
});

0 commit comments

Comments
 (0)