mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-20 00:11:37 +00:00
feat(mcp): add fail-closed read-only mode
This commit is contained in:
parent
c6445096eb
commit
17b32c5be2
3 changed files with 330 additions and 9 deletions
94
gitnexus/src/mcp/read-only-policy.ts
Normal file
94
gitnexus/src/mcp/read-only-policy.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import type { GITNEXUS_TOOLS } from './tools.js';
|
||||
|
||||
type GitNexusTool = (typeof GITNEXUS_TOOLS)[number];
|
||||
|
||||
export const MCP_READ_ONLY_TOOLS = new Set([
|
||||
'list_repos',
|
||||
'query',
|
||||
'context',
|
||||
'detect_changes',
|
||||
'check',
|
||||
'impact',
|
||||
'explain',
|
||||
'pdg_query',
|
||||
'route_map',
|
||||
'tool_map',
|
||||
'shape_check',
|
||||
'api_impact',
|
||||
'trace',
|
||||
]);
|
||||
|
||||
const MCP_READ_ONLY_ALIASES = new Set(['search', 'explore', 'overview']);
|
||||
|
||||
export function resolveMcpReadOnlyMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const value = env.GITNEXUS_MCP_READ_ONLY?.trim();
|
||||
if (value === undefined || value === '' || value === '0') return false;
|
||||
if (value === '1') return true;
|
||||
throw new Error('GITNEXUS_MCP_READ_ONLY must be 0 or 1.');
|
||||
}
|
||||
|
||||
export function assertMcpReadOnlyToolCall(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> | undefined,
|
||||
readOnly: boolean,
|
||||
): void {
|
||||
if (!readOnly) return;
|
||||
if (!MCP_READ_ONLY_TOOLS.has(toolName) && !MCP_READ_ONLY_ALIASES.has(toolName)) {
|
||||
throw new Error(`Tool "${toolName}" is not available in GitNexus MCP read-only mode.`);
|
||||
}
|
||||
if (typeof args?.repo === 'string' && args.repo.trim().startsWith('@')) {
|
||||
throw new Error('Group routing is not available in GitNexus MCP read-only mode.');
|
||||
}
|
||||
}
|
||||
|
||||
export function readOnlyResourceTemplateAllowed(uriTemplate: string, readOnly: boolean): boolean {
|
||||
return !readOnly || !uriTemplate.startsWith('gitnexus://group/');
|
||||
}
|
||||
|
||||
export function assertMcpReadOnlyResource(uri: string, readOnly: boolean): void {
|
||||
if (readOnly && uri.startsWith('gitnexus://group/')) {
|
||||
throw new Error('Group resources are not available in GitNexus MCP read-only mode.');
|
||||
}
|
||||
}
|
||||
|
||||
export function filterMcpReadOnlyResourceContent(content: string, readOnly: boolean): string {
|
||||
if (!readOnly) return content;
|
||||
return content
|
||||
.split('\n')
|
||||
.filter(
|
||||
(line) =>
|
||||
!/^\s*-\s+(?:rename|cypher|group_sync|group_list):/u.test(line) &&
|
||||
!/^\|\s*`(?:rename|cypher|group_sync|group_list)`\s*\|/u.test(line) &&
|
||||
!line.includes('gitnexus://group/'),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function scrubGroupDescription(description: string): string {
|
||||
return description
|
||||
.replace(/\nGROUP MODE:[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, '')
|
||||
.replace(/\nCROSS-REPO \(experimental\):[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, '')
|
||||
.replace(/\nDESTINATION TRACE \(cross-repo\):[\s\S]*?(?=\n\n[A-Z][A-Z ()-]*:|$)/gu, '');
|
||||
}
|
||||
|
||||
export function toolForReadOnlyMcp(tool: GitNexusTool, readOnly: boolean): GitNexusTool {
|
||||
if (!readOnly) return tool;
|
||||
|
||||
const properties = { ...tool.inputSchema.properties };
|
||||
const repo = properties.repo;
|
||||
if (repo && typeof repo === 'object') {
|
||||
properties.repo = {
|
||||
...repo,
|
||||
description:
|
||||
'Indexed repository name or path. Group-mode values beginning with @ are unavailable in MCP read-only mode.',
|
||||
};
|
||||
}
|
||||
delete properties.subgroup;
|
||||
delete properties.crossDepth;
|
||||
|
||||
return {
|
||||
...tool,
|
||||
description: `${scrubGroupDescription(tool.description)}\n\nGitNexus MCP read-only mode excludes raw Cypher, mutation, and group routing.`,
|
||||
inputSchema: { ...tool.inputSchema, properties },
|
||||
};
|
||||
}
|
||||
|
|
@ -27,6 +27,15 @@ import { GITNEXUS_TOOLS } from './tools.js';
|
|||
import { installGlobalStdoutSentinel } from './stdio-context.js';
|
||||
import type { LocalBackend } from './local/local-backend.js';
|
||||
import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js';
|
||||
import {
|
||||
assertMcpReadOnlyResource,
|
||||
assertMcpReadOnlyToolCall,
|
||||
filterMcpReadOnlyResourceContent,
|
||||
MCP_READ_ONLY_TOOLS,
|
||||
readOnlyResourceTemplateAllowed,
|
||||
resolveMcpReadOnlyMode,
|
||||
toolForReadOnlyMcp,
|
||||
} from './read-only-policy.js';
|
||||
|
||||
/**
|
||||
* Next-step hints appended to tool responses.
|
||||
|
|
@ -82,6 +91,7 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
|
|||
* Transport-agnostic — caller connects the desired transport.
|
||||
*/
|
||||
export function createMCPServer(backend: LocalBackend): Server {
|
||||
const readOnly = resolveMcpReadOnlyMode();
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkgVersion: string = require('../../package.json').version;
|
||||
const server = new Server(
|
||||
|
|
@ -113,7 +123,9 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
|
||||
// Handle list resource templates request (for dynamic resources)
|
||||
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
||||
const templates = getResourceTemplates();
|
||||
const templates = getResourceTemplates().filter((template) =>
|
||||
readOnlyResourceTemplateAllowed(template.uriTemplate, readOnly),
|
||||
);
|
||||
return {
|
||||
resourceTemplates: templates.map((t) => ({
|
||||
uriTemplate: t.uriTemplate,
|
||||
|
|
@ -129,7 +141,8 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
const { uri } = request.params;
|
||||
|
||||
try {
|
||||
const content = await readResource(uri, backend);
|
||||
assertMcpReadOnlyResource(uri, readOnly);
|
||||
const content = filterMcpReadOnlyResourceContent(await readResource(uri, backend), readOnly);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
|
|
@ -154,12 +167,14 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
|
||||
// Handle list tools request
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: GITNEXUS_TOOLS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: tool.annotations,
|
||||
})),
|
||||
tools: GITNEXUS_TOOLS.filter((tool) => !readOnly || MCP_READ_ONLY_TOOLS.has(tool.name))
|
||||
.map((tool) => toolForReadOnlyMcp(tool, readOnly))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: tool.annotations,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Handle tool calls — append next-step hints to guide agent workflow
|
||||
|
|
@ -167,7 +182,9 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
const result = await backend.callTool(name, args);
|
||||
const typedArgs = args as Record<string, unknown> | undefined;
|
||||
assertMcpReadOnlyToolCall(name, typedArgs, readOnly);
|
||||
const result = await backend.callTool(name, typedArgs);
|
||||
const resultText = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
const hint = getNextStepHint(name, args as Record<string, any> | undefined);
|
||||
|
||||
|
|
|
|||
210
gitnexus/test/unit/mcp-read-only.test.ts
Normal file
210
gitnexus/test/unit/mcp-read-only.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
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([]),
|
||||
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(['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('omits group resource templates and rejects direct group resource reads', async () => {
|
||||
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: 'gitnexus://group/acme/status' });
|
||||
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('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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue