diff --git a/README.md b/README.md index 1ad21cd66..8a3b0ba2e 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,15 @@ codex plugin marketplace add abhigyanpatwari/GitNexus +
+MCP read-only mode + +Set `GITNEXUS_MCP_READ_ONLY=1` before starting the MCP server to expose only the proven single-repository read surface. Raw `cypher`, rename and group tools, group routing, and group resources are omitted from discovery and rejected before backend dispatch. Tool descriptions and generated setup/context resources are scrubbed so they do not recommend unavailable routes. + +The default is unchanged when the variable is unset or `0`. Any other value fails server startup rather than silently weakening the policy. + +
+ ## CLI Reference Everyday commands: @@ -465,6 +474,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. | | `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). | | `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. | +| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. | diff --git a/gitnexus/src/mcp/read-only-policy.ts b/gitnexus/src/mcp/read-only-policy.ts new file mode 100644 index 000000000..066b73cde --- /dev/null +++ b/gitnexus/src/mcp/read-only-policy.ts @@ -0,0 +1,107 @@ +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 | 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 || !/^gitnexus:\/\/group\//iu.test(uriTemplate); +} + +export function assertMcpReadOnlyResource(uri: string, readOnly: boolean): void { + if (!readOnly) return; + + let isGroupResource = false; + try { + const parsed = new URL(uri); + isGroupResource = + parsed.protocol.toLowerCase() === 'gitnexus:' && parsed.hostname.toLowerCase() === 'group'; + } catch { + // Invalid resource URIs are rejected by the normal parser. This fallback + // keeps obviously group-shaped malformed inputs fail-closed as well. + isGroupResource = /^gitnexus:\/\/group(?:\/|$)/iu.test(uri); + } + + if (isGroupResource) { + 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 }, + }; +} diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index d4a7c58aa..8d2f37a09 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -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 | 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 | 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 | undefined); diff --git a/gitnexus/test/unit/mcp-read-only.test.ts b/gitnexus/test/unit/mcp-read-only.test.ts new file mode 100644 index 000000000..6ce63ac07 --- /dev/null +++ b/gitnexus/test/unit/mcp-read-only.test.ts @@ -0,0 +1,235 @@ +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|@/); + 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.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, + ); + }); +});