mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(mcp): enforce repository allowlist and default (#2465)
Merged with the read-only policy: both filters compose in server.ts. Review hardening: assertResourceUri compares the opaque host case insensitively (GITNEXUS://GROUP bypass) and gains the fail-closed fallback for unparseable URIs; the backend proxy now also intercepts queryClusters/queryProcesses/queryClusterDetail/queryProcessDetail; scrubGroupDescription is shared from read-only-policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
48a145a47f
10 changed files with 889 additions and 32 deletions
11
README.md
11
README.md
|
|
@ -328,6 +328,15 @@ The default is unchanged when the variable is unset or `0`. Any other value fail
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>MCP repository policy</strong></summary>
|
||||
|
||||
Set `GITNEXUS_MCP_ALLOWED_REPOS` to a comma-separated list of canonical registry names or absolute indexed paths. Entries are trimmed, resolved against the registry, and deduplicated at startup. When exactly one repository is allowed it becomes the implicit default; when several are allowed, callers must select one unless `GITNEXUS_MCP_DEFAULT_REPO` is also set.
|
||||
|
||||
The default repository must resolve to an allowed repository. Invalid, ambiguous, blank, or mismatched configuration fails startup before stdio or HTTP begins serving. The allowlist applies to tools, aliases, discovery, resources, templates, implicit resolution, and embedded HTTP; hidden repository details are not included in selection errors. Setting only `GITNEXUS_MCP_DEFAULT_REPO` chooses a default without restricting explicit repository selections. An allowed repository whose name is duplicated in the registry must be configured by path, and its context resource is only served for the unique name form.
|
||||
|
||||
</details>
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Everyday commands:
|
||||
|
|
@ -475,6 +484,8 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `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. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ export function filterMcpReadOnlyResourceContent(content: string, readOnly: bool
|
|||
.join('\n');
|
||||
}
|
||||
|
||||
function scrubGroupDescription(description: string): string {
|
||||
/** Shared with repository-policy.ts so both policies scrub identically. */
|
||||
export 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, '')
|
||||
|
|
|
|||
397
gitnexus/src/mcp/repository-policy.ts
Normal file
397
gitnexus/src/mcp/repository-policy.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import path from 'node:path';
|
||||
import type { LocalBackend, RepoListing } from './local/local-backend.js';
|
||||
import { parseListReposPagination } from './local/local-backend.js';
|
||||
import { scrubGroupDescription } from './read-only-policy.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 {
|
||||
// resources.ts parses with the same URL call, so anything that fails
|
||||
// here fails there too today. Keep obviously group- or repo-shaped
|
||||
// malformed inputs fail-closed anyway in case the parsers ever drift.
|
||||
if (/^gitnexus:\/\/group(?:\/|$)/iu.test(uri)) {
|
||||
throw new Error('Group resources are unavailable when an MCP repository allowlist is set.');
|
||||
}
|
||||
const repoShaped = /^gitnexus:\/\/repo\/([^/]+)/iu.exec(uri);
|
||||
if (repoShaped) this.resolveRuntimeRepo(decodeURIComponent(repoShaped[1]));
|
||||
return;
|
||||
}
|
||||
// gitnexus: is a non-special URL scheme, so the host is opaque and NOT
|
||||
// lowercased by the parser — compare case-insensitively like
|
||||
// read-only-policy.ts does.
|
||||
if (parsed.protocol.toLowerCase() !== 'gitnexus:') return;
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (hostname === 'group') {
|
||||
throw new Error('Group resources are unavailable when an MCP repository allowlist is set.');
|
||||
}
|
||||
if (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 = scrubGroupDescription(tool.description);
|
||||
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.',
|
||||
);
|
||||
};
|
||||
}
|
||||
// Repo-scoped resource reads must not depend on assertResourceUri
|
||||
// running first — enforce the allowlist on the query surface too.
|
||||
if (policy.restricted && (property === 'queryClusters' || property === 'queryProcesses')) {
|
||||
return (repoName?: string, limit?: number) => {
|
||||
const selected = policy.repoForArgs(
|
||||
repoName === undefined ? undefined : { repo: repoName },
|
||||
);
|
||||
return property === 'queryClusters'
|
||||
? target.queryClusters(selected?.path ?? repoName, limit)
|
||||
: target.queryProcesses(selected?.path ?? repoName, limit);
|
||||
};
|
||||
}
|
||||
if (
|
||||
policy.restricted &&
|
||||
(property === 'queryClusterDetail' || property === 'queryProcessDetail')
|
||||
) {
|
||||
return (name: string, repoName?: string) => {
|
||||
const selected = policy.repoForArgs(
|
||||
repoName === undefined ? undefined : { repo: repoName },
|
||||
);
|
||||
return property === 'queryClusterDetail'
|
||||
? target.queryClusterDetail(name, selected?.path ?? repoName)
|
||||
: target.queryProcessDetail(name, selected?.path ?? repoName);
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
|
@ -36,6 +36,11 @@ import {
|
|||
resolveMcpReadOnlyMode,
|
||||
toolForReadOnlyMcp,
|
||||
} from './read-only-policy.js';
|
||||
import {
|
||||
createMcpRepositoryPolicy,
|
||||
McpRepositoryPolicy,
|
||||
mcpRepositoryPolicyConfigured,
|
||||
} from './repository-policy.js';
|
||||
|
||||
/**
|
||||
* Next-step hints appended to tool responses.
|
||||
|
|
@ -90,8 +95,16 @@ 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 {
|
||||
const readOnly = resolveMcpReadOnlyMode();
|
||||
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(
|
||||
|
|
@ -123,8 +136,10 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
|
||||
// Handle list resource templates request (for dynamic resources)
|
||||
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
||||
const templates = getResourceTemplates().filter((template) =>
|
||||
readOnlyResourceTemplateAllowed(template.uriTemplate, readOnly),
|
||||
const templates = getResourceTemplates().filter(
|
||||
(template) =>
|
||||
readOnlyResourceTemplateAllowed(template.uriTemplate, readOnly) &&
|
||||
repositoryPolicy.resourceTemplateAllowed(template.uriTemplate),
|
||||
);
|
||||
return {
|
||||
resourceTemplates: templates.map((t) => ({
|
||||
|
|
@ -142,7 +157,11 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
|
||||
try {
|
||||
assertMcpReadOnlyResource(uri, readOnly);
|
||||
const content = filterMcpReadOnlyResourceContent(await readResource(uri, backend), readOnly);
|
||||
repositoryPolicy.assertResourceUri(uri);
|
||||
const content = filterMcpReadOnlyResourceContent(
|
||||
await readResource(uri, scopedBackend),
|
||||
readOnly,
|
||||
);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
|
|
@ -167,8 +186,12 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
|
||||
// Handle list tools request
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: GITNEXUS_TOOLS.filter((tool) => !readOnly || MCP_READ_ONLY_TOOLS.has(tool.name))
|
||||
.map((tool) => toolForReadOnlyMcp(tool, readOnly))
|
||||
tools: GITNEXUS_TOOLS.filter(
|
||||
(tool) =>
|
||||
(!readOnly || MCP_READ_ONLY_TOOLS.has(tool.name)) &&
|
||||
repositoryPolicy.toolAllowed(tool.name),
|
||||
)
|
||||
.map((tool) => toolForReadOnlyMcp(repositoryPolicy.toolForMcp(tool), readOnly))
|
||||
.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
|
|
@ -184,7 +207,7 @@ export function createMCPServer(backend: LocalBackend): Server {
|
|||
try {
|
||||
const typedArgs = args as Record<string, unknown> | undefined;
|
||||
assertMcpReadOnlyToolCall(name, typedArgs, readOnly);
|
||||
const result = await backend.callTool(name, typedArgs);
|
||||
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);
|
||||
|
||||
|
|
@ -332,8 +355,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);
|
||||
|
|
|
|||
|
|
@ -857,7 +857,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// Initialize MCP backend (multi-repo, shared across all MCP sessions)
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
const cleanupMcp = mountMCPEndpoints(app, backend);
|
||||
const cleanupMcp = await mountMCPEndpoints(app, backend);
|
||||
const jobManager = new JobManager();
|
||||
|
||||
// Backstop: remove any upload staging dirs orphaned by a previous crash.
|
||||
|
|
|
|||
|
|
@ -11,10 +11,15 @@
|
|||
import type { Express, Request, Response } from 'express';
|
||||
import { createStreamableHttpHandler } from '../mcp/http-transport.js';
|
||||
import type { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { createMcpRepositoryPolicy } from '../mcp/repository-policy.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
export function mountMCPEndpoints(app: Express, backend: LocalBackend): () => Promise<void> {
|
||||
const { handler, cleanup } = createStreamableHttpHandler(backend);
|
||||
export async function mountMCPEndpoints(
|
||||
app: Express,
|
||||
backend: LocalBackend,
|
||||
): Promise<() => Promise<void>> {
|
||||
const repositoryPolicy = await createMcpRepositoryPolicy(backend);
|
||||
const { handler, cleanup } = createStreamableHttpHandler(backend, { repositoryPolicy });
|
||||
|
||||
app.all('/api/mcp', (req: Request, res: Response) => {
|
||||
void handler(req, res).catch((err: unknown) => {
|
||||
|
|
|
|||
|
|
@ -638,18 +638,18 @@ describe('createSseHandlers', () => {
|
|||
// ─── mountMCPEndpoints refactor safety ───────────────────────────────
|
||||
|
||||
describe('mountMCPEndpoints', () => {
|
||||
it('returns a cleanup function', () => {
|
||||
it('returns a cleanup function', async () => {
|
||||
const backend = createMockBackend();
|
||||
const mockApp = {
|
||||
all: vi.fn(),
|
||||
};
|
||||
|
||||
const cleanup = mountMCPEndpoints(mockApp as never, backend as never);
|
||||
const cleanup = await mountMCPEndpoints(mockApp as never, backend as never);
|
||||
|
||||
expect(typeof cleanup).toBe('function');
|
||||
});
|
||||
|
||||
it('registers the /api/mcp route', () => {
|
||||
it('registers the /api/mcp route', async () => {
|
||||
const backend = createMockBackend();
|
||||
const allCalls: Array<[string, ...unknown[]]> = [];
|
||||
const mockApp = {
|
||||
|
|
@ -658,7 +658,7 @@ describe('mountMCPEndpoints', () => {
|
|||
}),
|
||||
};
|
||||
|
||||
mountMCPEndpoints(mockApp as never, backend as never);
|
||||
await mountMCPEndpoints(mockApp as never, backend as never);
|
||||
|
||||
const registeredPaths = allCalls.map(([path]) => path);
|
||||
expect(registeredPaths).toContain('/api/mcp');
|
||||
|
|
@ -670,7 +670,7 @@ describe('mountMCPEndpoints', () => {
|
|||
all: vi.fn(),
|
||||
};
|
||||
|
||||
const cleanup = mountMCPEndpoints(mockApp as never, backend as never);
|
||||
const cleanup = await mountMCPEndpoints(mockApp as never, backend as never);
|
||||
|
||||
await expect(cleanup()).resolves.not.toThrow();
|
||||
});
|
||||
|
|
|
|||
379
gitnexus/test/unit/mcp-repository-policy.test.ts
Normal file
379
gitnexus/test/unit/mcp-repository-policy.test.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
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';
|
||||
import { mountMCPEndpoints } from '../../src/server/mcp-http.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('enforces the allowlist on repo-scoped query methods without the resource guard', async () => {
|
||||
const backend = createBackend();
|
||||
const policy = await createMcpRepositoryPolicy(backend, {
|
||||
GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha',
|
||||
});
|
||||
const scoped = policy.scopeBackend(backend);
|
||||
|
||||
expect(() => scoped.queryClusters('Beta')).toThrow(/not available/i);
|
||||
expect(() => scoped.queryProcesses('Beta')).toThrow(/not available/i);
|
||||
expect(() => scoped.queryClusterDetail('area', 'Beta')).toThrow(/not available/i);
|
||||
expect(() => scoped.queryProcessDetail('proc', 'Beta')).toThrow(/not available/i);
|
||||
|
||||
await scoped.queryClusters();
|
||||
expect(backend.queryClusters).toHaveBeenCalledWith('/repos/alpha', undefined);
|
||||
await scoped.queryClusterDetail('area');
|
||||
expect(backend.queryClusterDetail).toHaveBeenCalledWith('area', '/repos/alpha');
|
||||
});
|
||||
|
||||
it.each(['GITNEXUS://GROUP/acme/status', 'gitnexus://user@group/acme/status'])(
|
||||
'rejects disguised group resource URI %s',
|
||||
async (uri) => {
|
||||
const backend = createBackend();
|
||||
const policy = await createMcpRepositoryPolicy(backend, {
|
||||
GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha',
|
||||
});
|
||||
expect(() => policy.assertResourceUri(uri)).toThrow(/group.*unavailable/i);
|
||||
},
|
||||
);
|
||||
|
||||
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('fails embedded HTTP startup before registering a route when policy is invalid', async () => {
|
||||
vi.stubEnv('GITNEXUS_MCP_ALLOWED_REPOS', 'Missing');
|
||||
const app = { all: vi.fn() };
|
||||
|
||||
await expect(mountMCPEndpoints(app as never, createBackend())).rejects.toThrow(
|
||||
/invalid repository selection/i,
|
||||
);
|
||||
expect(app.all).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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