mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
feat(mcp): enforce repository allowlist
This commit is contained in:
parent
c6445096eb
commit
181faa858c
5 changed files with 787 additions and 26 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<T extends { server: Server; lastActivity: number
|
|||
*/
|
||||
export function createStreamableHttpHandler(
|
||||
backend: LocalBackend,
|
||||
opts: { createServer?: () => Server; host?: string; port?: number } = {},
|
||||
opts: {
|
||||
createServer?: () => Server;
|
||||
host?: string;
|
||||
port?: number;
|
||||
repositoryPolicy?: McpRepositoryPolicy;
|
||||
} = {},
|
||||
): {
|
||||
handler: (req: Request, res: Response) => Promise<void>;
|
||||
cleanup: () => Promise<void>;
|
||||
} {
|
||||
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<McpRepositoryPolicy> | undefined = opts.repositoryPolicy
|
||||
? Promise.resolve(opts.repositoryPolicy)
|
||||
: undefined;
|
||||
const getRepositoryPolicy = (): Promise<McpRepositoryPolicy> =>
|
||||
(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<string, MCPSession>();
|
||||
|
|
@ -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<void>;
|
||||
messageHandler: (req: Request, res: Response) => Promise<void>;
|
||||
cleanup: () => Promise<void>;
|
||||
} {
|
||||
const maxSessions = opts.maxSessions ?? MAX_SESSIONS;
|
||||
let repositoryPolicy: Promise<McpRepositoryPolicy> | undefined = opts.repositoryPolicy
|
||||
? Promise.resolve(opts.repositoryPolicy)
|
||||
: undefined;
|
||||
const getRepositoryPolicy = (): Promise<McpRepositoryPolicy> =>
|
||||
(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<string, SSESession>();
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
362
gitnexus/src/mcp/repository-policy.ts
Normal file
362
gitnexus/src/mcp/repository-policy.ts
Normal file
|
|
@ -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<string>;
|
||||
private readonly defaultRepo?: ResolvedRepository;
|
||||
private readonly uniqueAllowedContextNames: ReadonlySet<string>;
|
||||
|
||||
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<string, number>();
|
||||
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<string, unknown> | 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<string, unknown> | undefined,
|
||||
): Record<string, unknown> | 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<RepoListing[]> {
|
||||
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<string, unknown> | undefined,
|
||||
): Promise<unknown> {
|
||||
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<string, unknown> | undefined,
|
||||
): Promise<unknown> {
|
||||
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<Awaited<ReturnType<LocalBackend['resolveRepo']>>> {
|
||||
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<string, unknown> | 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<McpRepositoryPolicy> {
|
||||
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<string, ResolvedRepository>();
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<string, any> | 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<string, unknown> | 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<string, any> | undefined);
|
||||
|
||||
|
|
@ -315,8 +334,12 @@ export function installSignalShutdown(
|
|||
on('SIGTERM', () => void shutdown(SHUTDOWN_EXIT_CODES.SIGTERM));
|
||||
}
|
||||
|
||||
export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
||||
const server = createMCPServer(backend);
|
||||
export async function startMCPServer(
|
||||
backend: LocalBackend,
|
||||
repositoryPolicy?: McpRepositoryPolicy,
|
||||
): Promise<void> {
|
||||
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);
|
||||
|
|
|
|||
339
gitnexus/test/unit/mcp-repository-policy.test.ts
Normal file
339
gitnexus/test/unit/mcp-repository-policy.test.ts
Normal file
|
|
@ -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<string, unknown>) => ({
|
||||
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|@<groupName>/);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue