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,
+ );
+ });
+});