fix(mcp): resolve omitted repo from cwd (#3085)

* fix(mcp): resolve omitted repo from cwd

* test(mcp): cover cwd repository routing gaps

* fix(mcp): harden cwd repository routing

* docs(mcp): clarify cwd repository boundary

* fix(mcp): preserve resolver compatibility

* fix(mcp): align restricted repository routing

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
简律纯 2026-08-30 05:07:43 +08:00 committed by GitHub
parent 170eefd4a0
commit 5bad2d8b0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 639 additions and 78 deletions

View file

@ -73,8 +73,8 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Wrong repo in multi-repo setups
- **Trigger:** Query/impact results belong to another project.
- **Do:** Call `list_repos`, then pass `repo` on subsequent tools.
- **Why:** Default target is ambiguous when multiple repos are registered.
- **Do:** Confirm an MCP default is configured or the GitNexus process was launched inside the intended registered path without crossing into an unindexed nested Git checkout. Otherwise call `list_repos`, then pass `repo` on subsequent tools; pass it for mutating tools when multiple repos are registered and no MCP default exists.
- **Why:** Read-only tools derive their default from MCP configuration or a process cwd that stays within one registered Git boundary. Outside those paths the target remains ambiguous, and mutating tools stay explicit unless configuration supplies the target.
### LadybugDB lock / "database busy"

View file

@ -179,7 +179,7 @@ flowchart TB
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
### Resources for instant context
@ -612,7 +612,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
<details>
<summary><strong>Architecture diagram</strong></summary>

View file

@ -204,7 +204,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
| `group_list` | List configured repository groups |
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
## MCP Resources

View file

@ -114,6 +114,9 @@ const PLATFORM_LOGIC = [
// POSIX and Windows — the fail-closed path-claim semantics must hold on the
// real windows-latest path implementation (#2419/#2420).
'test/unit/server-api-repo-resolution.test.ts',
// #3073: cwd-based repository selection canonicalizes real paths, compares
// platform separators/case, and rejects nested Git-boundary fallthrough.
'test/unit/calltool-dispatch.test.ts',
// The index write-lock (#2658) selects its backend by process.platform — the
// OS socket lock (Windows named pipe / Linux abstract socket) vs the file
// fallback — and its socket-backend describe block is gated to linux/win32.

View file

@ -38,6 +38,7 @@ import {
parseDiffHunks,
coalesceHunksByPath,
hunksOverlapRange,
findGitRootByDotGit,
getCanonicalRepoRoot,
getGitRoot,
type FileDiff,
@ -1716,21 +1717,61 @@ export class LocalBackend {
* - If only 1 repo, use it
* - If 0 or multiple without param, throw with helpful message
*
* On a miss, re-reads the registry once in case a new repo was indexed
* while the MCP server was running.
* Re-reads the registry before an omitted implicit target or after an
* explicit miss, so long-running servers see newly indexed repositories.
*/
async resolveRepo(repoParam?: string, branch?: string): Promise<RepoHandle> {
let refreshedAfterAmbiguity = false;
return this.selectToolRepository(repoParam, branch);
}
/**
* Internal resolver variant for CLI/MCP tool routing and discovery.
* - If repoParam is given, match by name or path
* - If only 1 repo, use it
* - If multiple repos exist and repoParam is omitted, callers may opt in to
* the registered repo containing process.cwd()
* - If 0 repos exist, or cwd cannot disambiguate multiple repos, throw
*
* Omitted-repo resolution re-reads the registry before accepting any
* implicit target, including a cached singleton. A caller that just obtained
* a fresh registry snapshot may disable that refresh explicitly.
*/
async selectToolRepository(
repoParam?: string,
branch?: string,
options: { allowCwdDefault?: boolean; refreshRegistry?: boolean } = {},
): Promise<RepoHandle> {
const allowCwdDefault = options.allowCwdDefault === true;
const mayRefresh = options.refreshRegistry !== false;
let refreshed = false;
// A cached singleton is also an implicit choice: another process may have
// registered a second repo since init, which must not let a repo-less
// mutating call bypass the multi-repo ambiguity guard.
if (!repoParam && mayRefresh) {
await this.refreshRepos();
refreshed = true;
}
let result: RepoHandle | null;
try {
result = this.resolveRepoFromCache(repoParam);
result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
} catch (err) {
if (!(err instanceof RegistryAmbiguousTargetError)) throw err;
if (!mayRefresh || refreshed) throw err;
// Stale in-memory duplicate siblings can linger after unregister; refresh
// once before re-throwing so a resolved registry can disambiguate (#1658).
await this.refreshRepos();
refreshedAfterAmbiguity = true;
result = this.resolveRepoFromCache(repoParam);
refreshed = true;
result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
}
// Explicit misses retain the existing one-refresh retry. Omitted targets
// already refreshed above unless a same-snapshot caller opted out.
if (!result && mayRefresh && !refreshed) {
await this.refreshRepos();
refreshed = true;
result = this.resolveRepoFromCache(repoParam, allowCwdDefault);
}
if (result) {
@ -1746,16 +1787,6 @@ export class LocalBackend {
return this.applyBranchScope(result, branch);
}
// Miss — refresh registry and try once more (skip if already refreshed above)
if (!refreshedAfterAmbiguity) {
await this.refreshRepos();
}
const retried = this.resolveRepoFromCache(repoParam);
if (retried) {
this.maybeWarnSiblingDrift(retried).catch(() => {});
return this.applyBranchScope(retried, branch);
}
// Still no match — throw with helpful message
if (this.repos.size === 0) {
throw new Error('No indexed repositories. Run: gitnexus analyze');
@ -1905,7 +1936,7 @@ export class LocalBackend {
* Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
* multiple handles by name and cwd cannot disambiguate (#1658).
*/
private resolveRepoFromCache(repoParam?: string): RepoHandle | null {
private resolveRepoFromCache(repoParam?: string, allowCwdDefault = false): RepoHandle | null {
if (this.repos.size === 0) return null;
if (repoParam) {
@ -1938,6 +1969,9 @@ export class LocalBackend {
);
if (nameMatches.length === 1) return nameMatches[0];
if (nameMatches.length > 1) {
// Explicit duplicate aliases retain the legacy fail-closed contract:
// only an exact cwd Git-root match may disambiguate them. Deepest path
// containment is reserved for an omitted read-only repo (#3073).
const cwdPick = this.pickRepoHandleForCwd(nameMatches);
if (cwdPick) return cwdPick;
throw new RegistryAmbiguousTargetError(
@ -1969,26 +2003,50 @@ export class LocalBackend {
return this.repos.values().next().value!;
}
if (allowCwdDefault) {
const cwdPick = this.pickRepoHandleForCwd([...this.repos.values()], true);
if (cwdPick) return cwdPick;
}
return null; // Multiple repos, no param — ambiguous
}
/**
* Prefer the indexed repo whose path matches the git root of process.cwd().
* Match process.cwd() against indexed repositories.
*
* In MCP stdio server mode, `process.cwd()` is the server's launch directory,
* not the agent client's cwd. If the server was started from an unrelated
* directory, `getGitRoot` returns null and duplicate-name resolution throws
* {@link RegistryAmbiguousTargetError} callers should pass an absolute path.
* Explicit duplicate aliases use exact Git-root matching only. Omitted
* read-only calls opt into deepest containing-path selection. In that mode a
* candidate must not sit above cwd's Git root, so an unindexed nested checkout
* cannot fall through to an indexed ancestor. The `.git` ancestor fallback
* preserves that boundary when the git executable is unavailable.
*/
private pickRepoHandleForCwd(candidates: RepoHandle[]): RepoHandle | null {
const cwdRoot = getGitRoot(process.cwd());
if (!cwdRoot) return null;
const canonicalCwd = canonicalizePath(cwdRoot);
private pickRepoHandleForCwd(
candidates: RepoHandle[],
allowContaining = false,
): RepoHandle | null {
const cwd = process.cwd();
const normalize = (value: string): string => {
const canonical = canonicalizePath(value);
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
};
const isSameOrDescendant = (parent: string, child: string): boolean =>
child === parent ||
child.startsWith(parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`);
const canonicalCwd = normalize(cwd);
const cwdRoot = getGitRoot(cwd) ?? findGitRootByDotGit(cwd);
const canonicalRoot = cwdRoot ? normalize(cwdRoot) : null;
if (allowContaining) {
const containing = candidates
.map((handle) => ({ handle, repoPath: normalize(handle.repoPath) }))
.filter(({ repoPath }) => isSameOrDescendant(repoPath, canonicalCwd))
.filter(({ repoPath }) => !canonicalRoot || isSameOrDescendant(canonicalRoot, repoPath))
.sort((a, b) => b.repoPath.length - a.repoPath.length);
if (containing.length > 0) return containing[0].handle;
}
if (!canonicalRoot) return null;
const cwdMatches = candidates.filter((handle) => {
const stored = canonicalizePath(handle.repoPath);
return process.platform === 'win32'
? stored.toLowerCase() === canonicalCwd.toLowerCase()
: stored === canonicalCwd;
return normalize(handle.repoPath) === canonicalRoot;
});
return cwdMatches.length === 1 ? cwdMatches[0] : null;
}
@ -2415,9 +2473,10 @@ export class LocalBackend {
// Resolve repo from optional param (re-reads registry on miss). An optional
// `branch` param scopes the resolved handle to that branch's index (#2106).
const repo = await this.resolveRepo(
const repo = await this.selectToolRepository(
p.repo as string | undefined,
p.branch as string | undefined,
{ allowCwdDefault: method !== 'rename' },
);
switch (method) {

View file

@ -179,9 +179,44 @@ export class McpRepositoryPolicy {
});
}
async requiresExplicitRepo(backend: LocalBackend): Promise<boolean> {
if (this.defaultRepo) return false;
return (await this.listAllowedRepos(backend)).length > 1;
async toolSchemaRepoRequirements(backend: LocalBackend): Promise<{
readOnlyRequiresRepo: boolean;
mutatingRequiresRepo: boolean;
}> {
if (this.defaultRepo) {
return { readOnlyRequiresRepo: false, mutatingRequiresRepo: false };
}
// Runtime selection is based on the configured allowlist, not on which
// entries happen to remain visible in a later registry refresh. Keep the
// advertised schema aligned with repoForArgs() when that listing shrinks.
if (this.restricted) {
const requiresRepo = this.allowed.length > 1;
return {
readOnlyRequiresRepo: requiresRepo,
mutatingRequiresRepo: requiresRepo,
};
}
// One fresh listing supplies both schema decisions. Besides keeping the
// advertised contract internally consistent, this avoids doing two full
// per-repo staleness fan-outs for every tools/list request.
const visibleRepos = await this.listAllowedRepos(backend);
if (visibleRepos.length <= 1) {
return { readOnlyRequiresRepo: false, mutatingRequiresRepo: false };
}
try {
// listAllowedRepos() refreshed this backend immediately above. Resolve
// against that exact cache snapshot instead of racing another registry
// read; only read-only schemas may advertise the cwd-derived default.
await backend.selectToolRepository(undefined, undefined, {
allowCwdDefault: true,
refreshRegistry: false,
});
return { readOnlyRequiresRepo: false, mutatingRequiresRepo: true };
} catch {
return { readOnlyRequiresRepo: true, mutatingRequiresRepo: true };
}
}
private async listReposPage(
@ -241,6 +276,22 @@ export class McpRepositoryPolicy {
return backend.resolveRepo(selected?.path, branch);
}
private async selectToolRepository(
backend: LocalBackend,
repo?: string,
branch?: string,
options?: Parameters<LocalBackend['selectToolRepository']>[2],
): Promise<Awaited<ReturnType<LocalBackend['selectToolRepository']>>> {
if (!this.configured) return backend.selectToolRepository(repo, branch, options);
if (!this.restricted) {
return backend.selectToolRepository(repo ?? this.defaultRepo?.path, branch, options);
}
const selected = this.repoForArgs(repo === undefined ? undefined : { repo });
// Restricted policies never allow cwd to select outside the configured
// set; once policy supplies an explicit path, the public resolver is enough.
return backend.resolveRepo(selected?.path, branch);
}
assertResourceUri(uri: string): void {
if (!this.restricted) return;
let parsed: URL;
@ -307,6 +358,13 @@ export class McpRepositoryPolicy {
if (property === 'resolveRepo') {
return (repo?: string, branch?: string) => policy.resolveRepo(target, repo, branch);
}
if (property === 'selectToolRepository') {
return (
repo?: string,
branch?: string,
options?: Parameters<LocalBackend['selectToolRepository']>[2],
) => policy.selectToolRepository(target, repo, branch, options);
}
if (property === 'getContext' && policy.restricted) {
return (repoId?: string) => {
if (!repoId || !policy.uniqueAllowedContextNames.has(repoId.toLowerCase())) return null;

View file

@ -313,7 +313,10 @@ async function getReposResource(backend: LocalBackend): Promise<string> {
if (repos.length > 1) {
lines.push('');
lines.push('# Multiple repos indexed. Use repo parameter in tool calls:');
lines.push(
'# Multiple repos indexed. Read-only tools may omit repo when an MCP default is configured or GitNexus process.cwd() is inside one listed path without crossing an unindexed nested Git checkout.',
);
lines.push('# Otherwise—and for mutating tools without an MCP default—pass repo explicitly:');
lines.push(`# query({search_query: "auth", repo: "${repos[0].name}"})`);
}

View file

@ -185,11 +185,12 @@ export function createMCPServer(
}
});
// With multiple visible repositories and no process-wide default, make the
// routing requirement machine-readable. Agents then supply `repo` before the
// call instead of discovering the ambiguity through a failed tool response.
// Make the effective routing contract machine-readable. Read-only tools may
// use a cwd-derived default; mutating rename remains explicit unless policy
// supplies a single/default repository.
server.setRequestHandler(ListToolsRequestSchema, async () => {
const requireRepo = await repositoryPolicy.requiresExplicitRepo(backend);
const { readOnlyRequiresRepo, mutatingRequiresRepo } =
await repositoryPolicy.toolSchemaRepoRequirements(backend);
return {
tools: GITNEXUS_TOOLS.filter(
(tool) =>
@ -201,7 +202,8 @@ export function createMCPServer(
name: tool.name,
description: tool.description,
inputSchema:
requireRepo && REPO_SCOPED_TOOLS.has(tool.name)
(tool.name === 'rename' ? mutatingRequiresRepo : readOnlyRequiresRepo) &&
REPO_SCOPED_TOOLS.has(tool.name)
? {
...tool.inputSchema,
required: [...new Set([...tool.inputSchema.required, 'repo'])],

View file

@ -82,6 +82,11 @@ export const PDG_QUERY_MAX_LIMIT = 200;
// PDG direct backend callers also enforce it before running traversal.
export const IMPACT_MAX_DEPTH = 32;
const CWD_AWARE_REPO_OMISSION =
'Omit when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing an unindexed nested Git checkout; otherwise specify it explicitly.';
const MUTATING_REPO_OMISSION =
'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo.';
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
@ -94,8 +99,10 @@ PAGINATION: Results are paginated so a large registry is not truncated by MCP/LL
WHEN TO USE: First step when multiple repos are indexed, or to discover available repos.
AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work with.
When multiple repos are indexed, you MUST specify the "repo" parameter
on other tools (query, context, impact, etc.) to target the correct one.`,
When multiple repos are indexed, repo-scoped read-only tools use the configured
MCP default or the registered path containing the GitNexus process cwd, unless
cwd has crossed into an unindexed nested Git checkout. If neither applies,
specify the "repo" parameter explicitly.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
@ -184,8 +191,7 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W
},
repo: {
type: 'string',
description:
'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>" (member path keys from group.yaml). Omit when only one indexed repo exists.',
description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>" (member path keys from group.yaml). ${CWD_AWARE_REPO_OMISSION}`,
},
service: {
type: 'string',
@ -266,7 +272,7 @@ TIPS:
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: ['statement'],
@ -331,8 +337,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
},
repo: {
type: 'string',
description:
'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". Omit if only one repo is indexed.',
description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". ${CWD_AWARE_REPO_OMISSION}`,
},
service: {
type: 'string',
@ -378,7 +383,7 @@ Returns: changed symbols, affected processes, and a risk summary.
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],
@ -417,7 +422,7 @@ A graph too large to analyze at all returns \`{ error, truncated: true }\` with
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],
@ -454,7 +459,7 @@ Handles disambiguation via context()'s payload verbatim: an ambiguous symbol_nam
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${MUTATING_REPO_OMISSION}`,
},
},
required: ['new_name'],
@ -593,8 +598,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
},
repo: {
type: 'string',
description:
'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". Omit if only one repo is indexed.',
description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". ${CWD_AWARE_REPO_OMISSION}`,
},
service: {
type: 'string',
@ -690,7 +694,7 @@ Findings are deliberately NOT part of impact()'s traversal or the web schema —
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],
@ -742,7 +746,7 @@ CONTRACT CAVEATS:
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: ['mode', 'target'],
@ -766,7 +770,7 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],
@ -784,7 +788,10 @@ Returns: tool nodes with their handler files and descriptions.`,
type: 'object',
properties: {
tool: { type: 'string', description: 'Filter by tool name. Omit for all tools.' },
repo: { type: 'string', description: 'Repository name or path.' },
repo: {
type: 'string',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],
},
@ -807,7 +814,7 @@ Returns routes that have both detected response keys AND consumers. Shows top-le
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],
@ -833,7 +840,10 @@ Response shape is keyed on how many routes match, not on the data: exactly one m
description:
'Optional HTTP verb — GET, POST, PUT, PATCH, DELETE, etc. — to narrow a multi-verb route or file lookup to a single method. Returns an error if no matched route uses that verb.',
},
repo: { type: 'string', description: 'Repository name or path.' },
repo: {
type: 'string',
description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],
},
@ -948,8 +958,7 @@ DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_fil
},
repo: {
type: 'string',
description:
'Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. Omit if only one repo is indexed.',
description: `Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. ${CWD_AWARE_REPO_OMISSION}`,
},
},
required: [],

View file

@ -420,7 +420,7 @@ describe('LocalBackend.callTool', () => {
['impact', { name: 'validate', symbol: 'login', direction: 'upstream' }],
['context', { name: 'validate', file_path: 'src/auth.ts', file: 'src/login.ts' }],
])('rejects conflicting %s aliases before repository resolution', async (method, params) => {
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
const resolveSpy = vi.spyOn(backend, 'selectToolRepository');
const result = await backend.callTool(method, params);
@ -434,7 +434,7 @@ describe('LocalBackend.callTool', () => {
['context', { name: 'validate', file: ' ' }],
['context', { name: 'validate', file: null }],
])('rejects invalid %s aliases before repository resolution', async (method, params) => {
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
const resolveSpy = vi.spyOn(backend, 'selectToolRepository');
const result = await backend.callTool(method, params);
@ -443,7 +443,7 @@ describe('LocalBackend.callTool', () => {
});
it('rejects a missing impact target before repository resolution', async () => {
const resolveSpy = vi.spyOn(backend, 'resolveRepo');
const resolveSpy = vi.spyOn(backend, 'selectToolRepository');
const result = await backend.callTool('impact', { direction: 'upstream' });
@ -3381,12 +3381,351 @@ describe('LocalBackend.resolveRepo', () => {
);
});
it('throws for ambiguous repos without param', async () => {
setupMultipleRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
'Multiple repositories indexed',
);
it('throws for ambiguous repos when cwd is outside every indexed path', async () => {
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project-sibling');
try {
setupMultipleRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
'Multiple repositories indexed',
);
} finally {
cwdSpy.mockRestore();
}
});
it('defaults to the deepest indexed repo containing cwd (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-outer-'));
const nestedDir = path.join(outerDir, 'packages', 'nested');
const cwdDir = path.join(nestedDir, 'src');
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir);
(listRegisteredRepos as any).mockResolvedValue([
{
...MOCK_REPO_ENTRY,
name: 'outer',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
},
{
...MOCK_REPO_ENTRY,
name: 'nested',
path: nestedDir,
storagePath: path.join(nestedDir, '.gitnexus'),
},
]);
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
const resolved = await backend.selectToolRepository(undefined, undefined, {
allowCwdDefault: true,
});
expect(resolved.repoPath).toBe(nestedDir);
const explicit = await backend.resolveRepo('outer');
expect(explicit.repoPath).toBe(outerDir);
} finally {
cwdSpy.mockRestore();
}
});
it('refreshes before accepting a cached cwd ancestor (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-stale-outer-'));
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-stale-other-'));
const nestedDir = path.join(outerDir, 'vendor', 'nested');
const cwdDir = path.join(nestedDir, 'src');
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir, otherDir);
const outerEntry = {
...MOCK_REPO_ENTRY,
name: 'outer',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
};
const nestedEntry = {
...MOCK_REPO_ENTRY,
name: 'nested',
path: nestedDir,
storagePath: path.join(nestedDir, '.gitnexus'),
};
const otherEntry = {
...MOCK_REPO_ENTRY,
name: 'other',
path: otherDir,
storagePath: path.join(otherDir, '.gitnexus'),
};
(listRegisteredRepos as any)
.mockResolvedValueOnce([outerEntry, otherEntry])
.mockResolvedValue([outerEntry, nestedEntry, otherEntry]);
(getGitRoot as any).mockImplementation((value: string) => {
const resolved = path.resolve(value);
if (resolved === nestedDir || resolved.startsWith(`${nestedDir}${path.sep}`)) {
return nestedDir;
}
if (resolved === outerDir || resolved.startsWith(`${outerDir}${path.sep}`)) {
return outerDir;
}
if (resolved === otherDir || resolved.startsWith(`${otherDir}${path.sep}`)) {
return otherDir;
}
return null;
});
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
const resolved = await backend.selectToolRepository(undefined, undefined, {
allowCwdDefault: true,
});
expect(resolved.repoPath).toBe(nestedDir);
expect(listRegisteredRepos).toHaveBeenCalledTimes(2);
} finally {
cwdSpy.mockRestore();
}
});
it('refreshes a cached singleton before repo-less read dispatch (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-singleton-outer-'));
const nestedDir = path.join(outerDir, 'packages', 'nested');
const cwdDir = path.join(nestedDir, 'src');
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir);
const outerEntry = {
...MOCK_REPO_ENTRY,
name: 'outer',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
};
const nestedEntry = {
...MOCK_REPO_ENTRY,
name: 'nested',
path: nestedDir,
storagePath: path.join(nestedDir, '.gitnexus'),
};
(listRegisteredRepos as any)
.mockResolvedValueOnce([outerEntry])
.mockResolvedValue([outerEntry, nestedEntry]);
(getGitRoot as any).mockReturnValue(outerDir);
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('cypher', { statement: 'MATCH (n) RETURN n LIMIT 1' });
expect((executeParameterized as any).mock.calls.at(-1)?.[0]).toBe(
path.join(nestedDir, '.gitnexus', 'lbug'),
);
expect(listRegisteredRepos).toHaveBeenCalledTimes(2);
} finally {
cwdSpy.mockRestore();
}
});
it('refreshes a cached singleton before enforcing repo-less rename safety (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rename-outer-'));
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rename-other-'));
const cwdDir = path.join(outerDir, 'src');
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir, otherDir);
const outerEntry = {
...MOCK_REPO_ENTRY,
name: 'outer',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
};
const otherEntry = {
...MOCK_REPO_ENTRY,
name: 'other',
path: otherDir,
storagePath: path.join(otherDir, '.gitnexus'),
};
(listRegisteredRepos as any)
.mockResolvedValueOnce([outerEntry])
.mockResolvedValue([outerEntry, otherEntry]);
(getGitRoot as any).mockReturnValue(outerDir);
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
await expect(
backend.callTool('rename', {
symbol_name: 'oldName',
new_name: 'newName',
dry_run: false,
}),
).rejects.toThrow('Multiple repositories indexed');
expect(listRegisteredRepos).toHaveBeenCalledTimes(2);
} finally {
cwdSpy.mockRestore();
}
});
it('keeps explicit duplicate aliases on exact git-root disambiguation (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-alias-outer-'));
const nestedDir = path.join(outerDir, 'packages', 'nested');
const cwdDir = path.join(nestedDir, 'src');
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir);
(listRegisteredRepos as any).mockResolvedValue([
{
...MOCK_REPO_ENTRY,
name: 'shared',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
},
{
...MOCK_REPO_ENTRY,
name: 'shared',
path: nestedDir,
storagePath: path.join(nestedDir, '.gitnexus'),
},
]);
(getGitRoot as any).mockReturnValue(outerDir);
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
const resolved = await backend.resolveRepo('shared');
expect(resolved.repoPath).toBe(outerDir);
} finally {
cwdSpy.mockRestore();
}
});
it('does not cross a nested git boundary when git root shelling fails (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rootless-outer-'));
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-rootless-other-'));
const nestedDir = path.join(outerDir, 'vendor', 'nested');
const cwdDir = path.join(nestedDir, 'src');
mkdirSync(path.join(nestedDir, '.git'), { recursive: true });
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir, otherDir);
(listRegisteredRepos as any).mockResolvedValue([
{
...MOCK_REPO_ENTRY,
name: 'outer',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
},
{
...MOCK_REPO_ENTRY,
name: 'other',
path: otherDir,
storagePath: path.join(otherDir, '.gitnexus'),
},
]);
(getGitRoot as any).mockReturnValue(null);
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
await expect(backend.callTool('query', { query: 'test' })).rejects.toThrow(
'Multiple repositories indexed',
);
} finally {
cwdSpy.mockRestore();
}
});
it('keeps cwd routing opt-in for direct backend helpers (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-direct-outer-'));
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-direct-other-'));
const cwdDir = path.join(outerDir, 'src');
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir, otherDir);
(listRegisteredRepos as any).mockResolvedValue([
{
...MOCK_REPO_ENTRY,
name: 'outer',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
},
{
...MOCK_REPO_ENTRY,
name: 'other',
path: otherDir,
storagePath: path.join(otherDir, '.gitnexus'),
},
]);
(getGitRoot as any).mockReturnValue(outerDir);
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
await expect(backend.queryProcesses()).rejects.toThrow('Multiple repositories indexed');
} finally {
cwdSpy.mockRestore();
}
});
it('does not default across an unindexed nested git boundary (#3073)', async () => {
const outerDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-git-outer-'));
const otherDir = mkdtempSync(path.join(os.tmpdir(), 'gnx-cwd-git-other-'));
const nestedDir = path.join(outerDir, 'vendor', 'nested');
const cwdDir = path.join(nestedDir, 'src');
mkdirSync(cwdDir, { recursive: true });
duplicateFixtureDirs.push(outerDir, otherDir);
(listRegisteredRepos as any).mockResolvedValue([
{
...MOCK_REPO_ENTRY,
name: 'outer',
path: outerDir,
storagePath: path.join(outerDir, '.gitnexus'),
},
{
...MOCK_REPO_ENTRY,
name: 'other',
path: otherDir,
storagePath: path.join(otherDir, '.gitnexus'),
},
]);
(getGitRoot as any).mockImplementation((value: string) => {
const resolved = path.resolve(value);
if (resolved === nestedDir || resolved.startsWith(`${nestedDir}${path.sep}`)) {
return nestedDir;
}
if (resolved === outerDir || resolved.startsWith(`${outerDir}${path.sep}`)) {
return outerDir;
}
if (resolved === otherDir || resolved.startsWith(`${otherDir}${path.sep}`)) {
return otherDir;
}
return null;
});
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwdDir);
try {
await backend.init();
await expect(
backend.selectToolRepository(undefined, undefined, { allowCwdDefault: true }),
).rejects.toThrow('Multiple repositories indexed');
} finally {
cwdSpy.mockRestore();
}
});
it('keeps mutating rename explicit with multiple repos (#3073)', async () => {
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp/test-project/src');
try {
setupMultipleRepos();
await backend.init();
await expect(
backend.callTool('rename', {
symbol_name: 'oldName',
new_name: 'newName',
dry_run: true,
}),
).rejects.toThrow('Multiple repositories indexed');
} finally {
cwdSpy.mockRestore();
}
});
it('resolves repo by name parameter', async () => {
@ -4682,7 +5021,7 @@ describe('LocalBackend tool-staleness cache keying (#2655 review)', () => {
lbugPath: `/r/.gitnexus/${path.join('branches', 'x', 'lbug')}`,
lastCommit: 'BRANCHSHA',
};
vi.spyOn(backend, 'resolveRepo')
vi.spyOn(backend, 'selectToolRepository')
.mockResolvedValueOnce(flat as any)
.mockResolvedValueOnce(branch as any);
// The tool itself returns a plain (staleness-carryable) object.
@ -4736,7 +5075,8 @@ describe('LocalBackend tool-staleness signal (#2655 review)', () => {
lastCommit: 'HEADSHA',
};
const stubResolve = () => vi.spyOn(backend, 'resolveRepo').mockResolvedValue(handle as any);
const stubResolve = () =>
vi.spyOn(backend, 'selectToolRepository').mockResolvedValue(handle as any);
const stubStale = async () => {
const { checkStalenessAsync } = await import('../../src/core/git-staleness.js');

View file

@ -46,6 +46,11 @@ function createBackend(repos = REPOS) {
repoPath: repo ?? repos[0]?.path,
lastCommit: 'a'.repeat(40),
})),
selectToolRepository: 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: [] }),
@ -138,6 +143,24 @@ describe('MCP repository policy', () => {
expect(backend.callTool).not.toHaveBeenCalled();
});
it('keeps restricted schemas explicit when a multi-repo allowlist listing shrinks', async () => {
const backend = createBackend();
const policy = await createMcpRepositoryPolicy(backend, {
GITNEXUS_MCP_ALLOWED_REPOS: 'Alpha,Beta',
});
const alpha = REPOS[0];
if (!alpha) throw new Error('Alpha fixture is required');
vi.mocked(backend.listRepos).mockResolvedValue([{ ...alpha }]);
await expect(policy.toolSchemaRepoRequirements(backend)).resolves.toEqual({
readOnlyRequiresRepo: true,
mutatingRequiresRepo: true,
});
await expect(
policy.scopeBackend(backend).callTool('query', { search_query: 'auth' }),
).rejects.toThrow(/explicit repo.*multiple repositories are allowed/i);
});
it('fails startup when the default is outside the allowlist after canonical resolution', async () => {
const backend = createBackend();
await expect(
@ -199,6 +222,7 @@ describe('MCP repository policy', () => {
const scoped = policy.scopeBackend(backend);
await expect(scoped.resolveRepo('Beta')).rejects.toThrow(/not available/i);
await expect(scoped.selectToolRepository('Beta')).rejects.toThrow(/not available/i);
await expect(scoped.readGroupStatusResource('portfolio')).rejects.toThrow(
/group.*unavailable/i,
);

View file

@ -396,7 +396,10 @@ describe('readResource', () => {
});
const result = await readResource('gitnexus://repos', backend);
expect(result).toContain('Multiple repos indexed');
expect(result).toContain('repo parameter');
expect(result).toContain('process.cwd()');
expect(result).toContain('unindexed nested Git checkout');
expect(result).toContain('mutating tools without an MCP default');
expect(result).toContain('pass repo explicitly');
// The example must use a registered tool name, not the unregistered
// `gitnexus_search` / `gitnexus_*` prefix (#2059).
// #2175: advertise the renamed param, not the legacy "query" key.

View file

@ -34,6 +34,9 @@ function createMockBackend(overrides: Record<string, any> = {}): any {
resolveRepo: vi
.fn()
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
selectToolRepository: vi
.fn()
.mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }),
getContext: vi.fn().mockReturnValue(null),
queryClusters: vi.fn().mockResolvedValue({ clusters: [] }),
queryProcesses: vi.fn().mockResolvedValue({ processes: [] }),
@ -105,12 +108,13 @@ describe('createMCPServer', () => {
await server.close();
}
});
it('requires repo in repo-scoped tool schemas when multiple repos are visible', async () => {
it('requires repo in repo-scoped tool schemas when cwd cannot resolve multiple repos', async () => {
const backend = createMockBackend({
listRepos: vi.fn().mockResolvedValue([
{ name: 'alpha', path: '/tmp/alpha' },
{ name: 'beta', path: '/tmp/beta' },
]),
selectToolRepository: vi.fn().mockRejectedValue(new Error('Multiple repositories indexed')),
});
const server = createMCPServer(backend);
const client = new Client({ name: 'multi-repo-client', version: '0.0.0' });
@ -133,6 +137,43 @@ describe('createMCPServer', () => {
}
});
it('keeps repo optional when cwd resolves one of multiple visible repos', async () => {
const backend = createMockBackend({
listRepos: vi.fn().mockResolvedValue([
{ name: 'alpha', path: '/tmp/alpha' },
{ name: 'beta', path: '/tmp/beta' },
]),
selectToolRepository: vi
.fn()
.mockResolvedValue({ name: 'alpha', repoPath: '/tmp/alpha', lastCommit: 'abc' }),
});
const server = createMCPServer(backend);
const client = new Client({ name: 'cwd-repo-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();
const context = tools.tools.find((tool) => tool.name === 'context');
const rename = tools.tools.find((tool) => tool.name === 'rename');
expect(context?.inputSchema.required).not.toContain('repo');
expect(rename?.inputSchema.required).toContain('repo');
const response = await client.callTool({ name: 'context', arguments: { name: 'Example' } });
expect(response.isError).not.toBe(true);
expect(backend.callTool).toHaveBeenCalledWith('context', { name: 'Example' });
expect(backend.listRepos).toHaveBeenCalledTimes(1);
expect(backend.selectToolRepository).toHaveBeenCalledTimes(1);
expect(backend.selectToolRepository).toHaveBeenCalledWith(undefined, undefined, {
allowCwdDefault: true,
refreshRegistry: false,
});
} finally {
await client.close();
await server.close();
}
});
it('keeps repo optional when a default repo is configured', async () => {
const backend = createMockBackend({
listRepos: vi.fn().mockResolvedValue([

View file

@ -283,6 +283,25 @@ describe('GITNEXUS_TOOLS', () => {
}
});
it('repo descriptions explain the cwd default and mutating exception (#3073)', () => {
expect(GITNEXUS_TOOLS.find((tool) => tool.name === 'list_repos')?.description).toMatch(
/process cwd/i,
);
expect(GITNEXUS_TOOLS.find((tool) => tool.name === 'list_repos')?.description).toMatch(
/unindexed nested Git checkout/i,
);
for (const tool of GITNEXUS_TOOLS) {
if (tool.name === 'list_repos' || GROUP_TOOLS.has(tool.name)) continue;
const description = tool.inputSchema.properties.repo.description;
if (tool.name === 'rename') {
expect(description).toMatch(/mutating tools require an explicit repo/i);
} else {
expect(description).toMatch(/process cwd/i);
expect(description).toMatch(/unindexed nested Git checkout/i);
}
}
});
it('per-repo tools have an optional branch scope param (#2106); group/list tools do not', () => {
for (const tool of GITNEXUS_TOOLS) {
if (tool.name === 'list_repos' || GROUP_TOOLS.has(tool.name)) {