GitNexus/gitnexus/test/unit/mcp-read-only.test.ts
Aakash Sharma d1a3edd333
perf(mcp): avoid O(n) git spawns on tools/list with many repos (#3259)
* perf(mcp): avoid O(n) git spawns on tools/list with many repos

toolSchemaRepoRequirements called listAllowedRepos -> listRepos -> checkStalenessAsync for every registered repo. With ~200 repos, this spawned 200 parallel git rev-list processes on every tools/list discovery call, causing a ~30s delay.

Replaced with a lightweight countRepos() method that reads the registry file once without spawning git processes, preserving full staleness checks for list_repos.

* Address PR review feedback (#3259)

Count the validated registry in countRepos so tools/list cannot advertise a multi-repo schema for ENOENT ghosts, and update the unrestricted listTools mocks to that contract.

Note: pre-existing failure in update-notice.test.ts (missing dist/cli/mcp.js) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Add a 200-repo tools/list bench for the countRepos path (#3259)

Pin the #1363 comparison (listRepos git fan-out vs validated countRepos / listTools) in-tree so the latency claim can be re-run. Also drop the change-history comments on the unrestricted schema arm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Gate the tools/list bench with baselines and CI --check (#3259)

Exact registry/schema floors plus ratio timing, no millisecond ceiling, so restoring listRepos() on tools/list fails CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3259)

Align unrestricted tools/list schema flags with the refreshed registry snapshot, and make the bench reject a non-positive BENCH_REPS and isolate fixtures by N.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3259)

Isolate the tools/list bench from GITNEXUS_MCP_READ_ONLY and create the default fixture under mkdtempSync so CodeQL is not looking at a predictable /tmp path.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-11 13:18:28 +01:00

252 lines
8.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createMCPServer } from '../../src/mcp/server.js';
import type { LocalBackend } from '../../src/mcp/local/local-backend.js';
const READ_ONLY_TOOLS = [
'api_impact',
'check',
'context',
'detect_changes',
'explain',
'impact',
'list_repos',
'pdg_query',
'query',
'route_map',
'shape_check',
'tool_map',
'trace',
];
function createMockBackend() {
return {
callTool: vi.fn().mockResolvedValue({ result: 'ok' }),
listRepos: vi.fn().mockResolvedValue([]),
countRepos: vi.fn().mockResolvedValue(0),
resolveRepo: vi
.fn()
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
getContext: vi.fn().mockReturnValue(null),
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }),
readGroupContractsResource: vi.fn().mockResolvedValue('contracts'),
readGroupStatusResource: vi.fn().mockResolvedValue('status'),
disconnect: vi.fn().mockResolvedValue(undefined),
};
}
async function connect(backend = createMockBackend()) {
const server = createMCPServer(backend as unknown as LocalBackend);
const client = new Client({ name: 'read-only-test-client', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
return {
backend,
client,
close: async () => {
await client.close();
await server.close();
},
};
}
function enableReadOnly(): void {
vi.stubEnv('GITNEXUS_MCP_READ_ONLY', '1');
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe('MCP read-only mode', () => {
it('discovers only proven single-repository read tools', async () => {
enableReadOnly();
const session = await connect();
try {
const response = await session.client.listTools();
expect(response.tools.map((tool) => tool.name).sort()).toEqual(READ_ONLY_TOOLS);
for (const tool of response.tools) {
expect(tool.description).not.toMatch(/GROUP MODE|CROSS-REPO|@<groupName>/);
const properties = tool.inputSchema.properties as Record<
string,
{ description?: string } | undefined
>;
const repo = properties.repo;
if (repo) expect(repo.description).not.toContain('@group');
expect(properties.subgroup).toBeUndefined();
expect(properties.crossDepth).toBeUndefined();
}
} finally {
await session.close();
}
});
it.each(['rename', 'group_sync', 'group_list', 'unknown_dynamic_tool'])(
'rejects hidden tool %s before backend dispatch',
async (name) => {
enableReadOnly();
const session = await connect();
try {
const response = await session.client.callTool({ name, arguments: {} });
expect(response.isError).toBe(true);
expect(response.content[0]).toMatchObject({ type: 'text' });
expect((response.content[0] as { text: string }).text).toMatch(/read-only mode/i);
expect(session.backend.callTool).not.toHaveBeenCalled();
} finally {
await session.close();
}
},
);
it.each(['CREATE (n:Injected)', 'MATCH (n) DETACH DELETE n', 'DROP TABLE Node'])(
'rejects raw cypher before backend dispatch: %s',
async (statement) => {
enableReadOnly();
const session = await connect();
try {
const response = await session.client.callTool({
name: 'cypher',
arguments: { repo: 'test', statement },
});
expect(response.isError).toBe(true);
expect((response.content[0] as { text: string }).text).toMatch(/read-only mode/i);
expect(session.backend.callTool).not.toHaveBeenCalled();
} finally {
await session.close();
}
},
);
it.each(['query', 'context', 'impact', 'trace'])(
'rejects @group routing through %s before backend dispatch',
async (name) => {
enableReadOnly();
const session = await connect();
try {
const response = await session.client.callTool({
name,
arguments: { repo: ' @portfolio/service-a ', target: 'auth', name: 'auth' },
});
expect(response.isError).toBe(true);
expect((response.content[0] as { text: string }).text).toMatch(/group.*read-only mode/i);
expect(session.backend.callTool).not.toHaveBeenCalled();
} finally {
await session.close();
}
},
);
it.each([
['impact', { target: 'auth', direction: 'upstream', crossDepth: 5 }],
['impact', { target: 'auth', direction: 'upstream', subgroup: 'services' }],
])('rejects group-only arguments before backend dispatch: %s %o', async (name, args) => {
enableReadOnly();
const session = await connect();
try {
const response = await session.client.callTool({ name, arguments: args });
expect(response.isError).toBe(true);
expect((response.content[0] as { text: string }).text).toMatch(/read-only mode/i);
expect(session.backend.callTool).not.toHaveBeenCalled();
} finally {
await session.close();
}
});
it.each(['search', 'explore', 'overview'])('preserves legacy read alias %s', async (name) => {
enableReadOnly();
const session = await connect();
try {
const response = await session.client.callTool({ name, arguments: { repo: 'test' } });
expect(response.isError).not.toBe(true);
expect(session.backend.callTool).toHaveBeenCalledWith(name, { repo: 'test' });
} finally {
await session.close();
}
});
it.each([
'gitnexus://group/acme/status',
'GITNEXUS://GROUP/acme/status',
'gitnexus://user@group/acme/status',
])('omits group resource templates and rejects disguised group resource read %s', async (uri) => {
enableReadOnly();
const session = await connect();
try {
const templates = await session.client.listResourceTemplates();
expect(templates.resourceTemplates.map((item) => item.uriTemplate)).not.toContain(
'gitnexus://group/{name}/contracts',
);
expect(templates.resourceTemplates.map((item) => item.uriTemplate)).not.toContain(
'gitnexus://group/{name}/status',
);
const resource = await session.client.readResource({ uri });
expect(resource.contents[0]).toMatchObject({ mimeType: 'text/plain' });
expect((resource.contents[0] as { text: string }).text).toMatch(/group.*read-only mode/i);
expect(session.backend.readGroupStatusResource).not.toHaveBeenCalled();
} finally {
await session.close();
}
});
it('leaves normal-mode discovery and dispatch unchanged', async () => {
const session = await connect();
try {
const tools = await session.client.listTools();
expect(tools.tools.map((tool) => tool.name)).toEqual(
expect.arrayContaining(['cypher', 'rename', 'group_list', 'group_sync']),
);
const response = await session.client.callTool({
name: 'cypher',
arguments: { statement: 'MATCH (n) RETURN n LIMIT 1' },
});
expect(response.isError).not.toBe(true);
expect(session.backend.callTool).toHaveBeenCalledWith('cypher', {
statement: 'MATCH (n) RETURN n LIMIT 1',
});
} finally {
await session.close();
}
});
it('scrubs hidden tools and group routes from generated resource discovery', async () => {
enableReadOnly();
const backend = createMockBackend();
backend.listRepos.mockResolvedValue([
{
name: 'test',
path: '/tmp/test',
indexedAt: '2026-01-01',
lastCommit: 'abc',
stats: { nodes: 2, edges: 1, processes: 0 },
},
]);
backend.getContext.mockReturnValue({
projectName: 'test',
stats: { fileCount: 1, functionCount: 2, processCount: 0 },
});
const session = await connect(backend);
try {
for (const uri of ['gitnexus://setup', 'gitnexus://repo/test/context']) {
const resource = await session.client.readResource({ uri });
const text = (resource.contents[0] as { text: string }).text;
expect(text).not.toMatch(/(?:^\s*-\s+|^\|\s*`)(?:rename|cypher)/mu);
expect(text).not.toContain('gitnexus://group/');
}
} finally {
await session.close();
}
});
it.each(['true', 'banana'])('fails startup for malformed read-only mode %s', (value) => {
vi.stubEnv('GITNEXUS_MCP_READ_ONLY', value);
expect(() => createMCPServer(createMockBackend() as unknown as LocalBackend)).toThrow(
/GITNEXUS_MCP_READ_ONLY must be 0 or 1/i,
);
});
});