feat(mcp): pare default tool surface to codegraph_explore alone + redux-thunk synthesizer
This commit is contained in:
@@ -1031,7 +1031,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
// The unrelated GitKraken hook survives untouched.
|
||||
expect(stopCommands.some((c: string) => c.includes('gk') && c.includes('ai hook run'))).toBe(true);
|
||||
// Permissions still written as normal alongside the cleanup.
|
||||
expect(after.permissions?.allow).toContain('mcp__codegraph__codegraph_search');
|
||||
expect(after.permissions?.allow).toContain('mcp__codegraph__*');
|
||||
});
|
||||
|
||||
it('claude: cleanupLegacyHooks preserves a sibling hook sharing our matcher group', () => {
|
||||
|
||||
@@ -17,18 +17,13 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => {
|
||||
|
||||
const listed = () => new ToolHandler(null).getTools().map(t => t.name).sort();
|
||||
|
||||
it('exposes the default 4-tool surface when unset', () => {
|
||||
it('exposes ONLY codegraph_explore by default when unset', () => {
|
||||
delete process.env[ENV];
|
||||
// The default set (see DEFAULT_MCP_TOOLS): explore + node are the
|
||||
// validated workhorses, search the cheap lookup, callers the one
|
||||
// irreplaceable enumerator. callees/impact/files/status stay defined
|
||||
// and executable but unlisted — impact appeared in ZERO recorded runs.
|
||||
expect(listed()).toEqual([
|
||||
'codegraph_callers',
|
||||
'codegraph_explore',
|
||||
'codegraph_node',
|
||||
'codegraph_search',
|
||||
]);
|
||||
// The default set (see DEFAULT_MCP_TOOLS) is pared to explore alone — the one
|
||||
// tool that earns its place (verbatim source grouped by file, plus the reasoned
|
||||
// flow map under the offload). node/search/callers/callees/impact/files/status
|
||||
// stay defined and executable but unlisted; CODEGRAPH_MCP_TOOLS re-enables them.
|
||||
expect(listed()).toEqual(['codegraph_explore']);
|
||||
});
|
||||
|
||||
it('re-enables an unlisted tool via the allowlist (impact)', () => {
|
||||
@@ -48,8 +43,7 @@ describe('CODEGRAPH_MCP_TOOLS allowlist', () => {
|
||||
|
||||
it('treats an empty/whitespace value as unset (default surface)', () => {
|
||||
process.env[ENV] = ' ';
|
||||
expect(listed()).toHaveLength(4);
|
||||
expect(listed()).toContain('codegraph_explore');
|
||||
expect(listed()).toEqual(['codegraph_explore']);
|
||||
});
|
||||
|
||||
it('rejects a disabled tool on execute (defense in depth)', async () => {
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('Unindexed-workspace session policy', () => {
|
||||
expect(instructions).toMatch(/inactive/i);
|
||||
expect(instructions).toMatch(/codegraph init/);
|
||||
// The full playbook must NOT be sent into a session where every call fails
|
||||
expect(instructions).not.toMatch(/Tool selection by intent/);
|
||||
expect(instructions).not.toMatch(/How to query/);
|
||||
expect(instructions).not.toMatch(/codegraph_explore/);
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('Unindexed-workspace session policy', () => {
|
||||
expect((res.result as { tools: unknown[] }).tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('an INDEXED workspace still gets the full playbook and all tools', async () => {
|
||||
it('an INDEXED workspace still gets the full playbook and the explore tool', async () => {
|
||||
fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export function hello(): string { return "hi"; }\n');
|
||||
const cg = await CodeGraph.init(tempDir, { index: true });
|
||||
cg.close();
|
||||
@@ -136,15 +136,15 @@ describe('Unindexed-workspace session policy', () => {
|
||||
child = spawnServer(tempDir);
|
||||
const init = await request(child, { id: 0, method: 'initialize', params: initializeParams(tempDir) });
|
||||
const instructions = (init.result as { instructions: string }).instructions;
|
||||
expect(instructions).toMatch(/Tool selection by intent/);
|
||||
expect(instructions).toMatch(/How to query/);
|
||||
expect(instructions).not.toMatch(/inactive/i);
|
||||
|
||||
const list = await request(child, { id: 1, method: 'tools/list' });
|
||||
const tools = (list.result as { tools: Array<{ name: string }> }).tools;
|
||||
// A 1-file project triggers the pre-existing tiny-repo tool gating (a
|
||||
// reduced core set) — the contract under test is "indexed → tools are
|
||||
// PRESENT", in contrast to the unindexed empty list above.
|
||||
expect(tools.length).toBeGreaterThanOrEqual(3);
|
||||
// The default surface is pared to explore alone (see DEFAULT_MCP_TOOLS) — the
|
||||
// contract under test is "indexed → tools are PRESENT", in contrast to the
|
||||
// unindexed empty list above.
|
||||
expect(tools.length).toBeGreaterThanOrEqual(1);
|
||||
expect(tools.map((t) => t.name)).toContain('codegraph_explore');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
/**
|
||||
* End-to-end test for the redux-thunk dispatch-chain synthesizer.
|
||||
*
|
||||
* `createAsyncThunk(prefix, async (a, api) => {...})` passes the async body as an argument, so
|
||||
* tree-sitter never makes it its own function node — the thunk `constant`'s body calls (incl.
|
||||
* `dispatch(nextThunk(...))`) are orphaned and `callees(thunk)` is empty. Verify the synthesizer
|
||||
* body-scans each thunk constant and links it → each dispatched thunk, so the chain
|
||||
* `outer → inner → deep` connects end-to-end; and that a non-thunk constant is skipped.
|
||||
*/
|
||||
describe('redux-thunk synthesizer', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redux-thunk-fixture-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('links each thunk constant to the thunks it dispatches, and skips non-thunks', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'package.json'),
|
||||
JSON.stringify({ name: 'app', dependencies: { '@reduxjs/toolkit': '^2' } })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'thunks.ts'),
|
||||
`import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
export const deepThunk = createAsyncThunk('app/deep', async (n: number) => {
|
||||
return n * 2;
|
||||
});
|
||||
|
||||
export const innerThunk = createAsyncThunk('app/inner', async (n: number, { dispatch }) => {
|
||||
return dispatch(deepThunk(n));
|
||||
});
|
||||
|
||||
export const outerThunk = createAsyncThunk('app/outer', async (n: number, { dispatch }) => {
|
||||
await dispatch(innerThunk(n));
|
||||
});
|
||||
|
||||
// Non-thunk constant that only MENTIONS dispatch in a string — must be skipped.
|
||||
export const notAThunk = 'dispatch(innerThunk())';
|
||||
`
|
||||
);
|
||||
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
|
||||
const db = (cg as any).db.db;
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT s.name source_name, s.kind source_kind, t.name target_name,
|
||||
json_extract(e.metadata,'$.via') via,
|
||||
json_extract(e.metadata,'$.registeredAt') registeredAt
|
||||
FROM edges e
|
||||
JOIN nodes s ON s.id = e.source
|
||||
JOIN nodes t ON t.id = e.target
|
||||
WHERE json_extract(e.metadata,'$.synthesizedBy') = 'redux-thunk'`
|
||||
)
|
||||
.all();
|
||||
cg.close?.();
|
||||
|
||||
// The dispatch chain connects: outer → inner → deep.
|
||||
const pairs = new Set(rows.map((r: any) => `${r.source_name}>${r.target_name}`));
|
||||
expect(pairs.has('outerThunk>innerThunk')).toBe(true);
|
||||
expect(pairs.has('innerThunk>deepThunk')).toBe(true);
|
||||
|
||||
// Sources are thunk constants; the non-thunk string constant is never a source.
|
||||
expect(rows.every((r: any) => r.source_kind === 'constant')).toBe(true);
|
||||
expect(rows.some((r: any) => r.source_name === 'notAThunk')).toBe(false);
|
||||
|
||||
// Edges are 'calls' with the wiring site surfaced for the agent.
|
||||
const outer = rows.find((r: any) => r.source_name === 'outerThunk');
|
||||
expect(outer.via).toBe('innerThunk');
|
||||
expect(outer.registeredAt).toMatch(/thunks\.ts:\d+/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user