From 181faa858c11da865c529636f4a0543536fbb376 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 02:01:00 +0700 Subject: [PATCH] feat(mcp): enforce repository allowlist --- gitnexus/src/cli/mcp.ts | 18 +- gitnexus/src/mcp/http-transport.ts | 47 ++- gitnexus/src/mcp/repository-policy.ts | 362 ++++++++++++++++++ gitnexus/src/mcp/server.ts | 47 ++- .../test/unit/mcp-repository-policy.test.ts | 339 ++++++++++++++++ 5 files changed, 787 insertions(+), 26 deletions(-) create mode 100644 gitnexus/src/mcp/repository-policy.ts create mode 100644 gitnexus/test/unit/mcp-repository-policy.test.ts diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 8b191db1d..9ad39268d 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -53,11 +53,13 @@ export const mcpCommand = async (options?: { // stdout at module init, but transitive deps (pino, pino-pretty, the // worker-thread transport) could in theory, and the import-closure // regression test enforces the leaf invariant. - const [{ startMCPServer }, { LocalBackend }, { logger }] = await Promise.all([ - import('../mcp/server.js'), - import('../mcp/local/local-backend.js'), - import('../core/logger.js'), - ]); + const [{ startMCPServer }, { LocalBackend }, { logger }, { createMcpRepositoryPolicy }] = + await Promise.all([ + import('../mcp/server.js'), + import('../mcp/local/local-backend.js'), + import('../core/logger.js'), + import('../mcp/repository-policy.js'), + ]); // Missing-optional-grammar warnings are intentionally NOT emitted here. // `gitnexus analyze` already warns at index time, filtered by the repo's @@ -71,7 +73,8 @@ export const mcpCommand = async (options?: { const backend = new LocalBackend(); await backend.init(); - const repos = await backend.listRepos(); + const repositoryPolicy = await createMcpRepositoryPolicy(backend); + const repos = await repositoryPolicy.scopeBackend(backend).listRepos(); if (repos.length === 0) { // Operator-actionable but the server still starts and serves; warn-level, // not error. Tools will discover newly-analyzed repos via lazy refresh. @@ -105,6 +108,7 @@ export const mcpCommand = async (options?: { port, host: options.host ?? '127.0.0.1', authToken: resolveAuthToken(options.authToken, process.env), + repositoryPolicy, }); } catch (err) { logger.error( @@ -117,5 +121,5 @@ export const mcpCommand = async (options?: { } // Start MCP server (serves all repos, discovers new ones lazily) - await startMCPServer(backend); + await startMCPServer(backend, repositoryPolicy); }; diff --git a/gitnexus/src/mcp/http-transport.ts b/gitnexus/src/mcp/http-transport.ts index 265ffa59a..68b897145 100644 --- a/gitnexus/src/mcp/http-transport.ts +++ b/gitnexus/src/mcp/http-transport.ts @@ -31,6 +31,11 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { createMCPServer, installSignalShutdown } from './server.js'; import type { LocalBackend } from './local/local-backend.js'; import { logger } from '../core/logger.js'; +import { + createMcpRepositoryPolicy, + mcpRepositoryPolicyConfigured, + type McpRepositoryPolicy, +} from './repository-policy.js'; /** HTTP server configuration options. */ export interface McpHttpOptions { @@ -40,6 +45,8 @@ export interface McpHttpOptions { host: string; /** Bearer auth token (optional; no auth when omitted). */ authToken?: string; + /** Prevalidated repository policy shared by startup logging and transports. */ + repositoryPolicy?: McpRepositoryPolicy; } interface MCPSession { @@ -217,13 +224,25 @@ export function startIdleSweep Server; host?: string; port?: number } = {}, + opts: { + createServer?: () => Server; + host?: string; + port?: number; + repositoryPolicy?: McpRepositoryPolicy; + } = {}, ): { handler: (req: Request, res: Response) => Promise; cleanup: () => Promise; } { + if (opts.createServer && !opts.repositoryPolicy && mcpRepositoryPolicyConfigured()) { + throw new Error('A custom MCP server factory cannot bypass configured repository policy.'); + } // Seam: tests inject createServer to observe the per-session Server lifecycle. - const createServer = opts.createServer ?? ((): Server => createMCPServer(backend)); + let repositoryPolicy: Promise | undefined = opts.repositoryPolicy + ? Promise.resolve(opts.repositoryPolicy) + : undefined; + const getRepositoryPolicy = (): Promise => + (repositoryPolicy ??= createMcpRepositoryPolicy(backend)); // DNS-rebinding protection (Host-header allowlist) when the bind host is known. const dnsRebinding = dnsRebindingOptions(opts.host, opts.port); const sessions = new Map(); @@ -280,7 +299,9 @@ export function createStreamableHttpHandler( sessionIdGenerator: () => randomUUID(), ...dnsRebinding, }); - const server = createServer(); + const server = opts.createServer + ? opts.createServer() + : createMCPServer(backend, { repositoryPolicy: await getRepositoryPolicy() }); await server.connect(transport); await transport.handleRequest(req, res, req.body); @@ -337,13 +358,23 @@ export function createStreamableHttpHandler( export function createSseHandlers( backend: LocalBackend, messagesPath = '/messages', - opts: { maxSessions?: number; host?: string; port?: number } = {}, + opts: { + maxSessions?: number; + host?: string; + port?: number; + repositoryPolicy?: McpRepositoryPolicy; + } = {}, ): { sseHandler: (req: Request, res: Response) => Promise; messageHandler: (req: Request, res: Response) => Promise; cleanup: () => Promise; } { const maxSessions = opts.maxSessions ?? MAX_SESSIONS; + let repositoryPolicy: Promise | undefined = opts.repositoryPolicy + ? Promise.resolve(opts.repositoryPolicy) + : undefined; + const getRepositoryPolicy = (): Promise => + (repositoryPolicy ??= createMcpRepositoryPolicy(backend)); // DNS-rebinding protection (Host-header allowlist) when the bind host is known. const dnsRebinding = dnsRebindingOptions(opts.host, opts.port); const sseSessions = new Map(); @@ -364,7 +395,7 @@ export function createSseHandlers( // SSEServerTransport(endpoint, res, options): endpoint is the path clients POST to. const transport = new SSEServerTransport(messagesPath, res, dnsRebinding); - const server = createMCPServer(backend); + const server = createMCPServer(backend, { repositoryPolicy: await getRepositoryPolicy() }); sseSessions.set(transport.sessionId, { server, transport, lastActivity: Date.now() }); @@ -451,6 +482,8 @@ export async function startMcpHttpServer( ); } + const repositoryPolicy = options.repositoryPolicy ?? (await createMcpRepositoryPolicy(backend)); + const app: Express = express(); // Suppress X-Powered-By to reduce information leakage. @@ -502,7 +535,7 @@ export async function startMcpHttpServer( }); // Streamable HTTP (modern MCP clients) at POST /mcp. - const streamable = createStreamableHttpHandler(backend, { host, port }); + const streamable = createStreamableHttpHandler(backend, { host, port, repositoryPolicy }); app.all('/mcp', auth, jsonBody, (req: Request, res: Response) => { void streamable.handler(req, res).catch((err: unknown) => { logger.error({ err }, 'MCP /mcp request failed'); @@ -517,7 +550,7 @@ export async function startMcpHttpServer( }); // Legacy SSE: GET /sse opens the stream; POST /messages receives JSON-RPC messages. - const sse = createSseHandlers(backend, '/messages', { host, port }); + const sse = createSseHandlers(backend, '/messages', { host, port, repositoryPolicy }); app.get('/sse', auth, (req: Request, res: Response) => { void sse.sseHandler(req, res).catch((err: unknown) => { logger.error({ err }, 'MCP /sse failed'); diff --git a/gitnexus/src/mcp/repository-policy.ts b/gitnexus/src/mcp/repository-policy.ts new file mode 100644 index 000000000..1b92cac7a --- /dev/null +++ b/gitnexus/src/mcp/repository-policy.ts @@ -0,0 +1,362 @@ +import path from 'node:path'; +import type { LocalBackend, RepoListing } from './local/local-backend.js'; +import { parseListReposPagination } from './local/local-backend.js'; +import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from './tools.js'; +import type { GITNEXUS_TOOLS } from './tools.js'; + +type GitNexusTool = (typeof GITNEXUS_TOOLS)[number]; + +const CANONICAL_ALLOWED = 'GITNEXUS_MCP_ALLOWED_REPOS'; +const CANONICAL_DEFAULT = 'GITNEXUS_MCP_DEFAULT_REPO'; + +interface RawRepositoryPolicy { + allowed?: string[]; + defaultRepo?: string; +} + +interface ResolvedRepository { + name: string; + path: string; + pathKey: string; +} + +function configuredValue( + env: NodeJS.ProcessEnv, + key: string, +): { key: string; value: string } | undefined { + const value = env[key]; + return value === undefined ? undefined : { key, value }; +} + +function parseRepositoryPolicy(env: NodeJS.ProcessEnv): RawRepositoryPolicy { + const allowedRaw = configuredValue(env, CANONICAL_ALLOWED); + const defaultRaw = configuredValue(env, CANONICAL_DEFAULT); + + let allowed: string[] | undefined; + if (allowedRaw) { + allowed = allowedRaw.value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + if (allowed.length === 0) throw new Error(`${allowedRaw.key} must not be blank.`); + } + + let defaultRepo: string | undefined; + if (defaultRaw) { + defaultRepo = defaultRaw.value.trim(); + if (!defaultRepo) throw new Error(`${defaultRaw.key} must not be blank.`); + } + + return { allowed, defaultRepo }; +} + +function normalizedPath(value: string): string { + const resolved = path.resolve(value); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +function isAbsolutePath(value: string): boolean { + return path.isAbsolute(value) || path.win32.isAbsolute(value); +} + +function resolveSpecifier( + specifier: string, + registry: readonly ResolvedRepository[], +): { repo?: ResolvedRepository; reason?: 'invalid' | 'ambiguous' } { + const trimmed = specifier.trim(); + const matches = isAbsolutePath(trimmed) + ? registry.filter((repo) => repo.pathKey === normalizedPath(trimmed)) + : registry.filter((repo) => repo.name.toLowerCase() === trimmed.toLowerCase()); + + if (matches.length === 0) return { reason: 'invalid' }; + if (matches.length > 1) return { reason: 'ambiguous' }; + return { repo: matches[0] }; +} + +function startupResolutionError(reason: 'invalid' | 'ambiguous'): Error { + return new Error( + reason === 'ambiguous' + ? 'MCP repository configuration contains an ambiguous repository selection.' + : 'MCP repository configuration contains an invalid repository selection.', + ); +} + +function unavailableRepositoryError(): Error { + return new Error('Repository is not available through this MCP server.'); +} + +export class McpRepositoryPolicy { + readonly restricted: boolean; + readonly configured: boolean; + + private readonly registry: readonly ResolvedRepository[]; + private readonly allowed: readonly ResolvedRepository[]; + private readonly allowedPathKeys: ReadonlySet; + private readonly defaultRepo?: ResolvedRepository; + private readonly uniqueAllowedContextNames: ReadonlySet; + + static unrestricted(): McpRepositoryPolicy { + return new McpRepositoryPolicy([], undefined, undefined); + } + + constructor( + registry: readonly ResolvedRepository[], + allowed: readonly ResolvedRepository[] | undefined, + defaultRepo: ResolvedRepository | undefined, + ) { + this.registry = registry; + this.restricted = allowed !== undefined; + this.configured = this.restricted || defaultRepo !== undefined; + this.allowed = allowed ?? registry; + this.allowedPathKeys = new Set(this.allowed.map((repo) => repo.pathKey)); + this.defaultRepo = defaultRepo; + + const registryNameCounts = new Map(); + for (const repo of registry) { + const name = repo.name.toLowerCase(); + registryNameCounts.set(name, (registryNameCounts.get(name) ?? 0) + 1); + } + this.uniqueAllowedContextNames = new Set( + this.allowed + .map((repo) => repo.name.toLowerCase()) + .filter((name) => registryNameCounts.get(name) === 1), + ); + } + + private resolveRuntimeRepo(specifier: string): ResolvedRepository { + const result = resolveSpecifier(specifier, this.registry); + if (!result.repo || (this.restricted && !this.allowedPathKeys.has(result.repo.pathKey))) { + throw unavailableRepositoryError(); + } + return result.repo; + } + + private repoForArgs(args: Record | undefined): ResolvedRepository | undefined { + const explicit = args?.repo; + if (explicit !== undefined) { + if (typeof explicit !== 'string') throw unavailableRepositoryError(); + if (explicit.trim().startsWith('@')) { + if (this.restricted) { + throw new Error('Group routing is unavailable when an MCP repository allowlist is set.'); + } + return undefined; + } + return this.resolveRuntimeRepo(explicit); + } + + if (this.defaultRepo) return this.defaultRepo; + if (this.restricted && this.allowed.length === 1) return this.allowed[0]; + if (this.restricted && this.allowed.length > 1) { + throw new Error('Specify an explicit repo because multiple repositories are allowed.'); + } + return undefined; + } + + private normalizeToolArgs( + args: Record | undefined, + ): Record | undefined { + if (!this.configured) return args; + if (!this.restricted && args?.repo !== undefined) return args; + const selected = this.repoForArgs(args); + if (!selected) return args; + return { ...(args ?? {}), repo: selected.path }; + } + + private async listAllowedRepos(backend: LocalBackend): Promise { + const current = await backend.listRepos(); + if (!this.restricted) return current; + return current + .filter((repo) => this.allowedPathKeys.has(normalizedPath(repo.path))) + .map((repo) => { + const siblings = repo.siblings?.filter((sibling) => + this.allowedPathKeys.has(normalizedPath(sibling.path)), + ); + return { + ...repo, + siblings: siblings && siblings.length > 0 ? siblings : undefined, + }; + }); + } + + private async listReposPage( + backend: LocalBackend, + params: Record | undefined, + ): Promise { + const { limit, offset } = parseListReposPagination(params, { + defaultLimit: LIST_REPOS_DEFAULT_LIMIT, + maxLimit: LIST_REPOS_MAX_LIMIT, + }); + const repositories = await this.listAllowedRepos(backend); + repositories.sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + if (an !== bn) return an < bn ? -1 : 1; + return a.path < b.path ? -1 : a.path > b.path ? 1 : 0; + }); + + const total = repositories.length; + const page = repositories.slice(offset, offset + limit); + const returned = page.length; + const hasMore = offset + returned < total; + return { + repositories: page, + pagination: { + total, + limit, + offset, + returned, + hasMore, + ...(hasMore && { nextOffset: offset + returned }), + }, + }; + } + + private async callTool( + backend: LocalBackend, + method: string, + params: Record | undefined, + ): Promise { + if (!this.configured) return backend.callTool(method, params); + if (method === 'list_repos') return this.listReposPage(backend, params); + if (this.restricted && method.startsWith('group_')) { + throw new Error('Group tools are unavailable when an MCP repository allowlist is set.'); + } + return backend.callTool(method, this.normalizeToolArgs(params)); + } + + private async resolveRepo( + backend: LocalBackend, + repo?: string, + branch?: string, + ): Promise>> { + if (!this.configured) return backend.resolveRepo(repo, branch); + if (!this.restricted) return backend.resolveRepo(repo ?? this.defaultRepo?.path, branch); + const selected = this.repoForArgs(repo === undefined ? undefined : { repo }); + return backend.resolveRepo(selected?.path, branch); + } + + assertResourceUri(uri: string): void { + if (!this.restricted) return; + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + return; + } + if (parsed.protocol !== 'gitnexus:') return; + if (parsed.hostname === 'group') { + throw new Error('Group resources are unavailable when an MCP repository allowlist is set.'); + } + if (parsed.hostname !== 'repo') return; + const repoName = parsed.pathname.split('/').filter(Boolean)[0]; + if (!repoName) return; + this.resolveRuntimeRepo(decodeURIComponent(repoName)); + } + + resourceTemplateAllowed(uriTemplate: string): boolean { + return !this.restricted || !uriTemplate.startsWith('gitnexus://group/'); + } + + toolAllowed(toolName: string): boolean { + return !this.restricted || !toolName.startsWith('group_'); + } + + toolForMcp(tool: GitNexusTool): GitNexusTool { + if (!this.restricted) return tool; + const properties = { ...tool.inputSchema.properties }; + const repo = properties.repo; + if (repo && typeof repo === 'object') { + properties.repo = { + ...repo, + description: 'Allowed indexed repository name or path. Group-mode values are unavailable.', + }; + } + delete properties.subgroup; + delete properties.crossDepth; + const description = tool.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, ''); + return { ...tool, description, inputSchema: { ...tool.inputSchema, properties } }; + } + + scopeBackend(backend: LocalBackend): LocalBackend { + const policy = this; + return new Proxy(backend, { + get(target, property, receiver) { + if (property === 'callTool') { + return (method: string, params: Record | undefined) => + policy.callTool(target, method, params); + } + if (property === 'listRepos') return () => policy.listAllowedRepos(target); + if (property === 'resolveRepo') { + return (repo?: string, branch?: string) => policy.resolveRepo(target, repo, branch); + } + if (property === 'getContext' && policy.restricted) { + return (repoId?: string) => { + if (!repoId || !policy.uniqueAllowedContextNames.has(repoId.toLowerCase())) return null; + return target.getContext(repoId); + }; + } + if ( + policy.restricted && + (property === 'readGroupContractsResource' || property === 'readGroupStatusResource') + ) { + return async () => { + throw new Error( + 'Group resources are unavailable when an MCP repository allowlist is set.', + ); + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + } +} + +export function mcpRepositoryPolicyConfigured(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = parseRepositoryPolicy(env); + return raw.allowed !== undefined || raw.defaultRepo !== undefined; +} + +export async function createMcpRepositoryPolicy( + backend: LocalBackend, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const raw = parseRepositoryPolicy(env); + if (!raw.allowed && !raw.defaultRepo) { + return McpRepositoryPolicy.unrestricted(); + } + + const registry = (await backend.listRepos()).map((repo) => ({ + name: repo.name, + path: repo.path, + pathKey: normalizedPath(repo.path), + })); + + let allowed: ResolvedRepository[] | undefined; + if (raw.allowed) { + const byPath = new Map(); + for (const specifier of raw.allowed) { + const result = resolveSpecifier(specifier, registry); + if (!result.repo) throw startupResolutionError(result.reason ?? 'invalid'); + byPath.set(result.repo.pathKey, result.repo); + } + allowed = [...byPath.values()]; + } + + let defaultRepo: ResolvedRepository | undefined; + if (raw.defaultRepo) { + const result = resolveSpecifier(raw.defaultRepo, registry); + if (!result.repo) throw startupResolutionError(result.reason ?? 'invalid'); + defaultRepo = result.repo; + } + + const defaultPathKey = defaultRepo?.pathKey; + if (defaultPathKey && allowed && !allowed.some((repo) => repo.pathKey === defaultPathKey)) { + throw new Error('The MCP default repository is not in the configured allowlist.'); + } + + return new McpRepositoryPolicy(registry, allowed, defaultRepo); +} diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index d4a7c58aa..4c6fd152b 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -27,6 +27,11 @@ 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 { + createMcpRepositoryPolicy, + McpRepositoryPolicy, + mcpRepositoryPolicyConfigured, +} from './repository-policy.js'; /** * Next-step hints appended to tool responses. @@ -81,7 +86,15 @@ function getNextStepHint(toolName: string, args: Record | undefined * Create a configured MCP Server with all handlers registered. * Transport-agnostic — caller connects the desired transport. */ -export function createMCPServer(backend: LocalBackend): Server { +export function createMCPServer( + backend: LocalBackend, + options: { repositoryPolicy?: McpRepositoryPolicy } = {}, +): Server { + if (!options.repositoryPolicy && mcpRepositoryPolicyConfigured()) { + throw new Error('Configured MCP repository policy must be validated before server creation.'); + } + const repositoryPolicy = options.repositoryPolicy ?? McpRepositoryPolicy.unrestricted(); + const scopedBackend = repositoryPolicy.scopeBackend(backend); const require = createRequire(import.meta.url); const pkgVersion: string = require('../../package.json').version; const server = new Server( @@ -113,7 +126,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) => + repositoryPolicy.resourceTemplateAllowed(template.uriTemplate), + ); return { resourceTemplates: templates.map((t) => ({ uriTemplate: t.uriTemplate, @@ -129,7 +144,8 @@ export function createMCPServer(backend: LocalBackend): Server { const { uri } = request.params; try { - const content = await readResource(uri, backend); + repositoryPolicy.assertResourceUri(uri); + const content = await readResource(uri, scopedBackend); return { contents: [ { @@ -154,12 +170,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) => repositoryPolicy.toolAllowed(tool.name)) + .map((tool) => repositoryPolicy.toolForMcp(tool)) + .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 +185,8 @@ 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; + const result = await scopedBackend.callTool(name, typedArgs); const resultText = typeof result === 'string' ? result : JSON.stringify(result, null, 2); const hint = getNextStepHint(name, args as Record | undefined); @@ -315,8 +334,12 @@ export function installSignalShutdown( on('SIGTERM', () => void shutdown(SHUTDOWN_EXIT_CODES.SIGTERM)); } -export async function startMCPServer(backend: LocalBackend): Promise { - const server = createMCPServer(backend); +export async function startMCPServer( + backend: LocalBackend, + repositoryPolicy?: McpRepositoryPolicy, +): Promise { + const validatedRepositoryPolicy = repositoryPolicy ?? (await createMcpRepositoryPolicy(backend)); + const server = createMCPServer(backend, { repositoryPolicy: validatedRepositoryPolicy }); // Idempotent global sentinel install. cli/mcp.ts calls this first thing // (before warnMissingOptionalGrammars / backend.init can emit to stdout); diff --git a/gitnexus/test/unit/mcp-repository-policy.test.ts b/gitnexus/test/unit/mcp-repository-policy.test.ts new file mode 100644 index 000000000..198b38a9d --- /dev/null +++ b/gitnexus/test/unit/mcp-repository-policy.test.ts @@ -0,0 +1,339 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { LocalBackend, RepoListing } from '../../src/mcp/local/local-backend.js'; +import { createMcpRepositoryPolicy } from '../../src/mcp/repository-policy.js'; +import { createMCPServer } from '../../src/mcp/server.js'; +import { createStreamableHttpHandler, startMcpHttpServer } from '../../src/mcp/http-transport.js'; + +const REPOS: RepoListing[] = [ + { + name: 'Alpha', + path: '/repos/alpha', + indexedAt: '2026-01-01', + lastCommit: 'a'.repeat(40), + }, + { + name: 'Beta', + path: '/repos/beta', + indexedAt: '2026-01-02', + lastCommit: 'b'.repeat(40), + }, + { + name: 'Duplicate', + path: '/repos/duplicate-one', + indexedAt: '2026-01-03', + lastCommit: 'c'.repeat(40), + }, + { + name: 'duplicate', + path: '/repos/duplicate-two', + indexedAt: '2026-01-04', + lastCommit: 'd'.repeat(40), + }, +]; + +function createBackend(repos = REPOS) { + return { + listRepos: vi.fn().mockResolvedValue(repos.map((repo) => ({ ...repo }))), + callTool: vi.fn().mockImplementation(async (name: string, args: Record) => ({ + name, + args, + })), + resolveRepo: vi.fn().mockImplementation(async (repo?: string) => ({ + name: repos.find((entry) => entry.path === repo)?.name ?? repo ?? repos[0]?.name, + repoPath: repo ?? repos[0]?.path, + lastCommit: 'a'.repeat(40), + })), + 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'), + } as unknown as LocalBackend; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('MCP repository policy', () => { + it('trims, resolves, and deduplicates configured repository specifiers', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: ' Alpha, /repos/beta, alpha, /repos/alpha ', + GITNEXUS_MCP_DEFAULT_REPO: ' ALPHA ', + }); + const scoped = policy.scopeBackend(backend); + + const repos = await scoped.listRepos(); + expect(repos.map((repo) => repo.name)).toEqual(['Alpha', 'Beta']); + + await scoped.callTool('query', { search_query: 'auth' }); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: '/repos/alpha', + }); + + await scoped.callTool('context', { name: 'auth', repo: ' beta ' }); + expect(backend.callTool).toHaveBeenLastCalledWith('context', { + name: 'auth', + repo: '/repos/beta', + }); + }); + + it('filters list_repos before applying pagination and totals', async () => { + const alpha = REPOS[0]; + if (!alpha) throw new Error('Alpha fixture is required'); + const backend = createBackend([ + { + ...alpha, + siblings: [{ name: 'Duplicate', path: '/repos/duplicate-one', lastCommit: 'c' }], + }, + ...REPOS.slice(1), + ]); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Beta,Alpha', + }); + const scoped = policy.scopeBackend(backend); + + const page = (await scoped.callTool('list_repos', { limit: 1, offset: 0 })) as { + repositories: RepoListing[]; + pagination: { total: number; returned: number; hasMore: boolean; nextOffset?: number }; + }; + expect(page.repositories.map((repo) => repo.name)).toEqual(['Alpha']); + expect(page.repositories[0]?.siblings).toBeUndefined(); + expect(page.pagination).toMatchObject({ + total: 2, + returned: 1, + hasMore: true, + nextOffset: 1, + }); + expect(backend.callTool).not.toHaveBeenCalled(); + }); + + it('uses the only allowed repository as the implicit default', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Beta', + }); + await policy.scopeBackend(backend).callTool('search', { query: 'auth' }); + expect(backend.callTool).toHaveBeenCalledWith('search', { + query: 'auth', + repo: '/repos/beta', + }); + }); + + it('requires an explicit repo when multiple repositories are allowed without a default', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Beta', + }); + await expect( + policy.scopeBackend(backend).callTool('query', { search_query: 'auth' }), + ).rejects.toThrow(/explicit repo.*multiple repositories are allowed/i); + expect(backend.callTool).not.toHaveBeenCalled(); + }); + + it('fails startup when the default is outside the allowlist after canonical resolution', async () => { + const backend = createBackend(); + await expect( + createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + GITNEXUS_MCP_DEFAULT_REPO: 'Beta', + }), + ).rejects.toThrow(/default repository is not in the configured allowlist/i); + }); + + it.each([ + [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Missing' }, 'invalid'], + [{ GITNEXUS_MCP_ALLOWED_REPOS: 'Duplicate' }, 'ambiguous'], + [{ GITNEXUS_MCP_DEFAULT_REPO: 'Duplicate' }, 'ambiguous'], + ])('fails startup with a sanitized %s configuration error', async (env, reason) => { + const backend = createBackend(); + let message = ''; + try { + await createMcpRepositoryPolicy(backend, env); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toMatch(new RegExp(reason, 'i')); + expect(message).not.toContain('/repos/'); + expect(message).not.toContain('Alpha'); + expect(message).not.toContain('Beta'); + }); + + it('allows a duplicate-name repository when configured by its unique path', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: '/repos/duplicate-two', + GITNEXUS_MCP_DEFAULT_REPO: '/repos/duplicate-two', + }); + await policy.scopeBackend(backend).callTool('overview', {}); + expect(backend.callTool).toHaveBeenCalledWith('overview', { repo: '/repos/duplicate-two' }); + }); + + it('rejects hidden and ambiguous selections without revealing registry contents', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + }); + const scoped = policy.scopeBackend(backend); + + for (const repo of ['Beta', 'Duplicate', '/repos/duplicate-two']) { + await expect(scoped.callTool('context', { name: 'auth', repo })).rejects.toThrow( + /repository is not available through this MCP server/i, + ); + } + expect(backend.callTool).not.toHaveBeenCalled(); + }); + + it('enforces the policy on resources and group methods', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + }); + const scoped = policy.scopeBackend(backend); + + await expect(scoped.resolveRepo('Beta')).rejects.toThrow(/not available/i); + await expect(scoped.readGroupStatusResource('portfolio')).rejects.toThrow( + /group.*unavailable/i, + ); + await expect(scoped.readGroupContractsResource('portfolio', {})).rejects.toThrow( + /group.*unavailable/i, + ); + await expect(scoped.callTool('group_list', {})).rejects.toThrow(/group.*unavailable/i); + await expect( + scoped.callTool('query', { repo: '@portfolio', search_query: 'auth' }), + ).rejects.toThrow(/group.*unavailable/i); + expect(backend.readGroupStatusResource).not.toHaveBeenCalled(); + }); + + it.each([{ GITNEXUS_MCP_ALLOWED_REPOS: ' ' }, { GITNEXUS_MCP_DEFAULT_REPO: ' ' }])( + 'fails closed for explicitly blank repository configuration', + async (env) => { + await expect(createMcpRepositoryPolicy(createBackend(), env)).rejects.toThrow( + /must not be blank/i, + ); + }, + ); + + it('is transparent when no repository policy is configured', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, {}); + await policy.scopeBackend(backend).callTool('query', { search_query: 'auth' }); + expect(backend.callTool).toHaveBeenCalledWith('query', { search_query: 'auth' }); + expect(await policy.scopeBackend(backend).listRepos()).toHaveLength(REPOS.length); + }); + + it('uses a configured default without restricting explicit dynamic selections', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_DEFAULT_REPO: 'Alpha', + }); + const scoped = policy.scopeBackend(backend); + + await scoped.callTool('query', { search_query: 'auth' }); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: '/repos/alpha', + }); + + await scoped.callTool('query', { search_query: 'auth', repo: 'newly-indexed' }); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: 'newly-indexed', + }); + }); + + it('enforces one policy across MCP tools, aliases, discovery, and resources', async () => { + const backend = createBackend(); + const policy = await createMcpRepositoryPolicy(backend, { + GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha', + GITNEXUS_MCP_DEFAULT_REPO: 'Alpha', + }); + const server = createMCPServer(backend, { repositoryPolicy: policy }); + const client = new Client({ name: 'repo-policy-client', version: '0.0.0' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + try { + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).not.toContain('group_list'); + expect(tools.tools.map((tool) => tool.name)).not.toContain('group_sync'); + for (const tool of tools.tools) { + expect(tool.description).not.toMatch(/GROUP MODE|CROSS-REPO|@/); + } + + const templates = await client.listResourceTemplates(); + expect( + templates.resourceTemplates.every((item) => !item.uriTemplate.includes('/group/')), + ).toBe(true); + + const repos = await client.callTool({ name: 'list_repos', arguments: {} }); + const reposText = (repos.content[0] as { text: string }).text; + expect(reposText).toContain('Alpha'); + expect(reposText).not.toContain('Beta'); + expect(reposText).not.toContain('Duplicate'); + + const query = await client.callTool({ + name: 'query', + arguments: { search_query: 'auth' }, + }); + expect(query.isError).not.toBe(true); + expect(backend.callTool).toHaveBeenLastCalledWith('query', { + search_query: 'auth', + repo: '/repos/alpha', + }); + + const hiddenAlias = await client.callTool({ + name: 'search', + arguments: { query: 'auth', repo: 'Beta' }, + }); + expect(hiddenAlias.isError).toBe(true); + expect((hiddenAlias.content[0] as { text: string }).text).toMatch(/not available/i); + + const reposResource = await client.readResource({ uri: 'gitnexus://repos' }); + const resourceText = (reposResource.contents[0] as { text: string }).text; + expect(resourceText).toContain('Alpha'); + expect(resourceText).not.toContain('Beta'); + + const setupResource = await client.readResource({ uri: 'gitnexus://setup' }); + const setupText = (setupResource.contents[0] as { text: string }).text; + expect(setupText).toContain('Alpha'); + expect(setupText).not.toContain('Beta'); + + const hiddenResource = await client.readResource({ + uri: 'gitnexus://repo/Beta/schema', + }); + expect((hiddenResource.contents[0] as { text: string }).text).toMatch(/not available/i); + } finally { + await client.close(); + await server.close(); + } + }); + + it('refuses direct server construction when configured policy was not prevalidated', () => { + vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Alpha'); + expect(() => createMCPServer(createBackend())).toThrow(/must be validated/i); + }); + + it('fails standalone HTTP startup before binding when registry policy is invalid', async () => { + vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Missing'); + await expect( + startMcpHttpServer(createBackend(), { host: '127.0.0.1', port: 0 }), + ).rejects.toThrow(/invalid repository selection/i); + }); + + it('rejects a custom HTTP server factory that would bypass configured policy', () => { + vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Alpha'); + expect(() => + createStreamableHttpHandler(createBackend(), { + createServer: () => createMCPServer(createBackend()), + }), + ).toThrow(/cannot bypass configured repository policy/i); + }); +});