mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat(mcp): add read_file + grep tools (REST parity for /api/file slice + /api/grep) * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3377) - Fail read_file and grep when full source is unavailable, matching the HTTP 410 contract instead of an empty grep or a not-found on a missing checkout. - Reject branch on those tools so a pinned index is not labeled onto checkout bytes, and stop advertising branch in their schemas. - Point the grep hint at a 0-based read_file window, pass caseSensitive and literal through, and test the handlers. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3377) - Keep read_file and grep in the multi-repo schema requirement without advertising branch. - Reject negative maxLines and return integer slice bounds for fractional line positions. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3377) - Reject a negative read_file endLine before slicing so JavaScript does not treat it as an offset from the end of the file. - Drop the fractional startLine/endLine claim so the integer schema is the advertised contract. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3377) - Skip indexed grep paths whose realpath leaves the checkout so a symlink cannot return lines from outside the repo. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(bench): record the 19-tool MCP roster read_file and grep are real tools, so tools/list and GITNEXUS_TOOLS both moved from 17 to 19. The timing ratios were already inside budget. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(mcp): share read_file and grep contracts with existing helpers Boolean grep flags go through isFlagTrue, the whole-file cap is one constant, and checkout tools stay on the per-repo schema without advertising branch. --------- Co-authored-by: svjack <svjack@example.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
254 lines
8.8 KiB
TypeScript
254 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',
|
|
'grep',
|
|
'impact',
|
|
'list_repos',
|
|
'pdg_query',
|
|
'query',
|
|
'read_file',
|
|
'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,
|
|
);
|
|
});
|
|
});
|