feat(mcp): paginate list_repos to avoid client token truncation (#2119) (#2120)

* feat(mcp): paginate list_repos to avoid client token truncation (#2119)

list_repos returned every indexed repository in one unpaginated array,
which large/LLM MCP clients truncate by token limit — so agents with
hundreds of indexed repos could not enumerate them all (the data
transmits fully; the consuming client drops it).

Add bounded limit/offset pagination to the list_repos tool:
- result changes from a bare array to
  { repositories, pagination: { total, limit, offset, returned,
  hasMore, nextOffset } }; default page 50, max 200 (shared constants)
- reject malformed limit/offset; clamp limit above the max
- deterministic order (lower-cased name, then path) over one registry
  snapshot per call, so paging never skips or duplicates an entry
- covers both stdio and remote /api/mcp (shared createMCPServer/callTool)

The internal listRepos() method (5 callers), GET /api/repos, and the
`gitnexus list` CLI are unchanged. The array->object tool-result shape
is a deliberate contract change, documented in CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): reject list_repos limit above the max instead of clamping (#2119)

parseListReposPagination silently clamped limit>max to the maximum while
throwing on every other out-of-bounds value (limit<1, offset<0, non-integer,
NaN). A client that advanced offset by its requested limit (rather than
pagination.nextOffset) then silently skipped repositories and saw
hasMore:false — defeating the "never skips" guarantee. Reject an over-max
limit too, so validation is symmetric and a caller never gets a smaller page
than it asked for without a clear error. Updates the schema/description, the
helper + ListReposPagination JSDoc, the guide note, and the two clamp tests.

Resolves the cross-engine-corroborated P2 (Codex + adversarial lane) and the
maintainability lane's clamp-vs-throw inconsistency from the PR #2120 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): name the list_repos return type and mark the parser @internal

Extract the inline listRepos() element shape into an exported RepoListing
interface and use it for both listRepos() and listReposPage().repositories,
replacing the opaque Awaited<ReturnType<LocalBackend['listRepos']>> expression
the maintainability review flagged. Tag parseListReposPagination @internal
(it is exported only for unit testing). Pure type/JSDoc change; no behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(eval-server): type formatListReposResult to the paginated shape

Narrow formatListReposResult's parameter from `any` to
{ repositories: RepoListing[]; pagination?: ListReposPagination } and drop the
dead bare-array branch — after #2119 callTool('list_repos') always returns the
paginated object, so the Array.isArray shim was unreachable. Add a list_repos
continuation hint to the eval-server's getNextStepHint (parity with the MCP
server), and cover the previously-untested non-empty + hasMore:false formatter
branch. Migrates the two bare-array formatter tests to the object shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): harden list_repos pagination coverage

- Exercise the #2054 sibling-clone guarantee through the real callTool tool
  path (in the #2054 describe, which has temp-dir cleanup), proving siblings
  and remoteUrl survive listReposPage's sort+slice — not only listRepos().
- Assert total + limit on the middle-page test (a total miscalculation at a
  non-zero offset would otherwise slip past it).
- Cover the benign boundaries: negative-zero offset (accepted as page 0) and a
  MAX_SAFE_INTEGER offset (empty page).
- Replace the integration test's '\n\n---' split with a string-aware brace
  scan, so a repo path containing braces can never truncate the JSON parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): sync the list_repos pagination example to the guide mirrors

The .claude and gitnexus-claude-plugin guide mirrors only carried the one-line
table note; add the full "Paginating list_repos" section (shape + multi-page
traversal example + notes) so all three guide copies are byte-consistent with
the canonical gitnexus/skills/gitnexus-guide.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: drop list_repos CHANGELOG entries from this PR

Restore gitnexus/CHANGELOG.md to match main so this PR contributes no
changelog change; the changelog is curated separately from feature PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-09 19:59:54 +01:00 • committed by GitHub
parent bd90d5bf90
commit 4682a477d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 737 additions and 56 deletions

View file

@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
## Resources Reference

View file

@ -347,7 +347,7 @@ It is opt-in and a no-op without `UNDERSTAND_QUICKLY_TOKEN` — a fine-grained G
| Tool | What It Does | `repo` Param |
| ----------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |

View file

@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
## Resources Reference

View file

@ -126,7 +126,7 @@ Your AI agent gets these tools automatically:
| Tool | What It Does | `repo` Param |
| ---------------- | ---------------------------------------------------------------- | ------------ |
| `list_repos` | Discover all indexed repositories | — |
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) | — |
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) | Optional |
| `context` | 360-degree symbol view — categorized refs, process participation | Optional |
| `impact` | Blast radius analysis with depth grouping and confidence | Optional |

View file

@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns:
```jsonc
{
"repositories": [
{ "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } }
],
"pagination": {
"total": 437,
"limit": 50,
"offset": 0,
"returned": 50,
"hasMore": true,
"nextOffset": 50
}
}
```
To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`:
```text
list_repos {} → repos 1–50, nextOffset 50, hasMore true
list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true
…
list_repos { offset: 400 } → repos 401–437, hasMore false (done)
```
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
## Resources Reference

View file

@ -32,7 +32,11 @@
import http from 'http';
import { isIPv4, isIPv6 } from 'node:net';
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import {
LocalBackend,
type RepoListing,
type ListReposPagination,
} from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
import { cliInfo, cliWarn, cliError } from './cli-message.js';
import { formatDetectChangesResult } from './detect-changes-format.js';
@ -265,13 +269,22 @@ export function formatCypherResult(result: any): string {
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
export function formatListReposResult(result: any): string {
if (!Array.isArray(result) || result.length === 0) {
return 'No indexed repositories.';
export function formatListReposResult(result: {
repositories: RepoListing[];
pagination?: ListReposPagination;
}): string {
// `list_repos` always returns the paginated { repositories, pagination } object (#2119).
const repos = result.repositories;
const pg = result.pagination;
if (repos.length === 0) {
return pg && pg.total > 0
? `No repositories on this page (offset ${pg.offset} of ${pg.total} total).`
: 'No indexed repositories.';
}
const lines = ['Indexed repositories:\n'];
for (const r of result) {
for (const r of repos) {
const stats = r.stats || {};
lines.push(
` ${r.name} — ${stats.nodes || '?'} symbols, ${stats.edges || '?'} relationships, ${stats.processes || '?'} flows`,
@ -279,6 +292,13 @@ export function formatListReposResult(result: any): string {
lines.push(` Path: ${r.path}`);
lines.push(` Indexed: ${r.indexedAt}`);
}
if (pg) {
lines.push('');
lines.push(
` Showing ${repos.length} of ${pg.total} (offset ${pg.offset}).` +
(pg.hasMore ? ` More available — re-run with offset ${pg.nextOffset}.` : ''),
);
}
return lines.join('\n');
}
@ -325,6 +345,9 @@ function getNextStepHint(toolName: string): string {
case 'detect_changes':
return '\n---\nNext: Run gitnexus-context "<symbol>" on high-risk changed symbols to check their callers.';
case 'list_repos':
return '\n---\nNext: READ gitnexus://repo/{name}/context for a repo above. If pagination.hasMore is true, re-run list_repos with offset set to pagination.nextOffset to page through the rest.';
default:
return '';
}

View file

@ -51,6 +51,7 @@ import {
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
import { logger } from '../../core/logger.js';
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -353,6 +354,84 @@ interface ImpactParams {
summaryOnly?: boolean;
}
/**
* One repository entry as returned by {@link LocalBackend.listRepos} and in each
* `list_repos` page. Named so the `listRepos`/`listReposPage` return types read
* clearly instead of an opaque `Awaited<ReturnType<…>>` expression.
*/
export interface RepoListing {
name: string;
path: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: any;
staleness?: { commitsBehind: number; hint?: string };
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
}
/** Continuation metadata for the paginated `list_repos` MCP tool (#2119). */
export interface ListReposPagination {
/** Total repositories across all pages. */
total: number;
/** Effective page size used (equals the requested limit; out-of-range is rejected, not clamped). */
limit: number;
/** Offset this page started at. */
offset: number;
/** Number of repositories actually returned in this page. */
returned: number;
/** True when more repositories remain past this page. */
hasMore: boolean;
/** Offset to request next; present only when `hasMore` is true. */
nextOffset?: number;
}
/**
* Validate and normalise `list_repos` pagination arguments.
*
* @internal Exported for unit testing; not part of the public API surface.
*
* There is NO MCP-SDK-level enforcement of a tool's advertised `inputSchema`
* (the SDK validates only the JSON-RPC envelope), and `callTool` is reachable
* directly, so the backend is the real validation boundary. Malformed values —
* non-number, `NaN`, non-integer, `limit < 1`, `limit > maxLimit`, or
* `offset < 0` — are REJECTED with a clear error. `limit` is bounded but NOT
* silently clamped: an over-max value throws (symmetric with the other bounds)
* so a client never receives a smaller page than it asked for without knowing.
* An omitted value (only `undefined`) falls back to the default.
*/
export function parseListReposPagination(
params: { limit?: unknown; offset?: unknown } | null | undefined,
opts: { defaultLimit: number; maxLimit: number },
): { limit: number; offset: number } {
const requireInt = (value: unknown, field: string, min: number, max?: number): number => {
const valid =
typeof value === 'number' &&
Number.isInteger(value) &&
value >= min &&
(max === undefined || value <= max);
if (!valid) {
const bound = max === undefined ? `>= ${min}` : `between ${min} and ${max}`;
throw new Error(
`list_repos: "${field}" must be an integer ${bound} (received ${JSON.stringify(value)})`,
);
}
return value;
};
let limit = opts.defaultLimit;
if (params?.limit !== undefined) {
limit = requireInt(params.limit, 'limit', 1, opts.maxLimit);
}
let offset = 0;
if (params?.offset !== undefined) {
offset = requireInt(params.offset, 'offset', 0);
}
return { limit, offset };
}
export class LocalBackend {
private repos: Map<string, RepoHandle> = new Map();
private contextCache: Map<string, CodebaseContext> = new Map();
@ -841,18 +920,7 @@ export class LocalBackend {
* that another clone of the same logical repo is registered).
* - `remoteUrl`: the canonical origin URL recorded at index time.
*/
async listRepos(): Promise<
Array<{
name: string;
path: string;
indexedAt: string;
lastCommit: string;
remoteUrl?: string;
stats?: any;
staleness?: { commitsBehind: number; hint?: string };
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
}>
> {
async listRepos(): Promise<RepoListing[]> {
await this.refreshRepos();
const handles = [...this.repos.values()];
@ -906,6 +974,58 @@ export class LocalBackend {
});
}
/**
* Paginated view over {@link listRepos} for the `list_repos` MCP tool (#2119).
*
* `listRepos()` itself still returns the FULL array — its resource and CLI
* consumers (`gitnexus://repos`, `gitnexus://setup`, startup logs) need every
* entry, so pagination lives ONLY here, on the tool surface, to keep the
* response under MCP/LLM token-truncation limits.
*
* Determinism: a single registry snapshot is taken per call, then sorted by
* lower-cased name with the repository path as a tie-breaker. Sibling clones
* share a name but never a path (#2054), so `(name, path)` is a total order —
* paging never skips or duplicates an entry while the registry is unchanged.
* Codepoint comparison (not `localeCompare`) keeps page boundaries stable
* across machines/locales, matching the existing `refreshRepos` ordering.
*/
async listReposPage(params?: { limit?: unknown; offset?: unknown } | null): Promise<{
repositories: RepoListing[];
pagination: ListReposPagination;
}> {
const { limit, offset } = parseListReposPagination(params, {
defaultLimit: LIST_REPOS_DEFAULT_LIMIT,
maxLimit: LIST_REPOS_MAX_LIMIT,
});
// One consistent snapshot per call (listRepos refreshes the registry once),
// sorted into a stable total order before slicing.
const all = await this.listRepos();
all.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 = all.length;
const repositories = all.slice(offset, offset + limit);
const returned = repositories.length;
const hasMore = offset + returned < total;
return {
repositories,
pagination: {
total,
limit,
offset,
returned,
hasMore,
...(hasMore && { nextOffset: offset + returned }),
},
};
}
/**
* Best-effort sibling-clone drift warning.
*
@ -967,7 +1087,10 @@ export class LocalBackend {
async callTool(method: string, params: any): Promise<any> {
if (method === 'list_repos') {
return this.listRepos();
// Paginated tool surface (#2119). `listRepos()` is unchanged for internal
// callers; the tool wraps it in { repositories, pagination } and forwards
// the limit/offset args that this dispatch previously discarded.
return this.listReposPage(params);
}
if (method.startsWith('group_')) {

View file

@ -44,7 +44,7 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
switch (toolName) {
case 'list_repos':
return `\n\n---\n**Next:** READ gitnexus://repo/{name}/context for any repo above to get its overview and check staleness.`;
return `\n\n---\n**Next:** READ gitnexus://repo/{name}/context for any repo above to get its overview and check staleness. If pagination.hasMore is true, call list_repos again with offset set to pagination.nextOffset to fetch the rest.`;
case 'query':
return `\n\n---\n**Next:** To understand a specific symbol in depth, use context({name: "<symbol_name>"${repoParam}}) to see categorized refs and process participation.`;

View file

@ -51,12 +51,25 @@ const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
openWorldHint: false,
};
/**
* Pagination bounds for the `list_repos` tool. Exported so the backend
* validation (`local-backend.ts`) and the schema below stay a single source of
* truth. `list_repos` is paginated to keep its response under MCP/LLM token
* truncation limits when many repos are indexed (#2119); the default page is
* small enough to render safely, and `LIST_REPOS_MAX_LIMIT` caps how much a
* caller can pull in one request.
*/
export const LIST_REPOS_DEFAULT_LIMIT = 50;
export const LIST_REPOS_MAX_LIMIT = 200;
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
description: `List all indexed repositories available to GitNexus.
description: `List indexed repositories available to GitNexus (paginated).
Returns each repo's name, path, indexed date, last commit, and stats.
Returns a page of repositories — each with name, path, indexed date, last commit, and stats — plus a "pagination" object: { total, limit, offset, returned, hasMore, nextOffset }.
PAGINATION: Results are paginated so a large registry is not truncated by MCP/LLM token limits. "limit" sets the page size (default ${LIST_REPOS_DEFAULT_LIMIT}, max ${LIST_REPOS_MAX_LIMIT}; values above the max are rejected, not capped). "offset" selects the start. To enumerate EVERY repository: when pagination.hasMore is true, call list_repos again with offset set to pagination.nextOffset, and repeat until hasMore is false. Repositories are returned in a stable order, so paging never skips or duplicates an entry while the registry is unchanged.
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.
@ -66,7 +79,22 @@ on other tools (query, context, impact, etc.) to target the correct one.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {},
properties: {
limit: {
type: 'integer',
description: `Max repositories to return in this page (default: ${LIST_REPOS_DEFAULT_LIMIT}, min: 1, max: ${LIST_REPOS_MAX_LIMIT}). Values outside [1, ${LIST_REPOS_MAX_LIMIT}] are rejected.`,
default: LIST_REPOS_DEFAULT_LIMIT,
minimum: 1,
maximum: LIST_REPOS_MAX_LIMIT,
},
offset: {
type: 'integer',
description:
'Number of repositories to skip before this page (default: 0). Pass pagination.nextOffset from the previous response to fetch the next page.',
default: 0,
minimum: 0,
},
},
required: [],
},
},

View file

@ -189,7 +189,7 @@ function spawnMcpServer(): SpawnedServer {
}
describe('MCP server end-to-end startup', () => {
it('preserves JSON-RPC stdout discipline through initialize + tools/list', async () => {
it('preserves JSON-RPC stdout discipline through initialize + tools/list + tools/call', async () => {
if (!fs.existsSync(DIST_CLI)) {
throw new Error(
`dist/cli/index.js missing — run \`npm run build\` first (or use \`npm run test:integration\` which builds via pretest:integration).`,
@ -253,6 +253,53 @@ describe('MCP server end-to-end startup', () => {
expect(toolNames).toContain(t);
}
// tools/call list_repos — proves the paginated { repositories, pagination }
// shape survives the real request → backend.callTool → JSON.stringify →
// content[0].text serialization path (#2119), independent of repo count.
server.send({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'list_repos', arguments: { limit: 5 } },
});
const callResponse = (await server.nextMessage()) as {
id: number;
result?: { content?: Array<{ type: string; text: string }>; isError?: boolean };
};
expect(callResponse.id).toBe(3);
expect(callResponse.result?.isError).not.toBe(true);
const callText = callResponse.result!.content![0].text;
// The server appends a non-JSON next-step hint after the JSON payload.
// Extract the leading JSON object with a string-aware brace scan so a repo
// path containing braces can never truncate the parse (more robust than
// splitting on the hint's separator).
const jsonStart = callText.indexOf('{');
let depth = 0;
let inStr = false;
let esc = false;
let jsonEnd = callText.length;
for (let i = jsonStart; i < callText.length; i++) {
const ch = callText[i];
if (esc) {
esc = false;
} else if (ch === '\\') {
esc = true;
} else if (ch === '"') {
inStr = !inStr;
} else if (!inStr && ch === '{') {
depth++;
} else if (!inStr && ch === '}' && --depth === 0) {
jsonEnd = i + 1;
break;
}
}
const payload = JSON.parse(callText.slice(jsonStart, jsonEnd));
expect(Array.isArray(payload.repositories)).toBe(true);
expect(typeof payload.pagination.total).toBe('number');
expect(payload.pagination.limit).toBe(5);
expect(payload.pagination.offset).toBe(0);
expect(payload.repositories.length).toBeLessThanOrEqual(5);
// The headline assertion: every byte the server emitted on stdout
// must reassemble into a valid JSON-RPC frame. Any leftover is a
// protocol-corruption regression.

View file

@ -85,7 +85,11 @@ vi.mock('../../src/mcp/core/embedder.js', () => ({
getEmbeddingDims: vi.fn().mockReturnValue(384),
}));
import { LocalBackend, REPO_ID_HASH_LENGTH } from '../../src/mcp/local/local-backend.js';
import {
LocalBackend,
REPO_ID_HASH_LENGTH,
parseListReposPagination,
} from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js';
import { getGitRoot } from '../../src/storage/git.js';
import { _captureLogger } from '../../src/core/logger.js';
@ -276,9 +280,18 @@ describe('LocalBackend.callTool', () => {
});
it('routes list_repos without needing repo param', async () => {
// No-arg compatibility: callTool('list_repos', {}) returns the first page as
// a { repositories, pagination } object (Strategy A — always paginated, #2119).
const result = await backend.callTool('list_repos', {});
expect(Array.isArray(result)).toBe(true);
expect(result[0].name).toBe('test-project');
expect(Array.isArray(result.repositories)).toBe(true);
expect(result.repositories[0].name).toBe('test-project');
expect(result.pagination).toEqual({
total: 1,
limit: 50,
offset: 0,
returned: 1,
hasMore: false,
});
});
it('throws for unknown tool name', async () => {
@ -1165,7 +1178,7 @@ describe('LocalBackend.resolveRepo', () => {
it('resolves single repo without param', async () => {
setupSingleRepo();
await backend.init();
const result = await backend.callTool('list_repos', {});
const result = await backend.listRepos();
expect(result).toHaveLength(1);
});
@ -1383,6 +1396,25 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
}
});
it('serves all sibling clones through the list_repos tool with siblings/remoteUrl intact (#2054, #2119)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
// Exercise the real TOOL surface (callTool → listReposPage), not just
// listRepos(): the paginated wrapper must not drop sibling-clone fields
// during its sort + slice.
const page = await backend.callTool('list_repos', {});
expect(page.repositories).toHaveLength(4);
expect(page.pagination.total).toBe(4);
const paths = page.repositories.map((r: any) => path.resolve(r.path)).sort();
expect(paths).toEqual(dirs.map((d) => path.resolve(d)).sort());
for (const entry of page.repositories) {
expect(entry.remoteUrl).toBe('git@github.com:MYCOMPANY/REPO.git');
expect(entry.siblings).toHaveLength(3);
}
});
it('lists all four sibling clones that share a name and remote (#2054)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
(listRegisteredRepos as any).mockResolvedValue(entries);
@ -1394,7 +1426,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
expect(await backend.init()).toBe(true);
const listed = await backend.callTool('list_repos', {});
const listed = await backend.listRepos();
expect(listed).toHaveLength(4);
// Every distinct on-disk clone survives exactly once — no silent overwrite.
@ -1416,7 +1448,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
}
// Re-running list_repos (which re-reads the registry) is idempotent.
const again = await backend.callTool('list_repos', {});
const again = await backend.listRepos();
expect(again).toHaveLength(4);
});
@ -1474,7 +1506,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
it('refresh stability: reorder, remove-one, and re-add never drop a different clone (#2054)', async () => {
const { dirs, entries } = makeSiblingClonesFixture(4);
const listedPaths = async () =>
(await backend.callTool('list_repos', {})).map((r: any) => path.resolve(r.path)).sort();
(await backend.listRepos()).map((r: any) => path.resolve(r.path)).sort();
const allPaths = dirs.map((d) => path.resolve(d)).sort();
(listRegisteredRepos as any).mockResolvedValue(entries);
@ -1627,7 +1659,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const listed = await backend.callTool('list_repos', {});
const listed = await backend.listRepos();
expect(listed).toHaveLength(6);
// All six ids are distinct (clones 3–6 exercise the sha256 fallback tier).
@ -1643,7 +1675,7 @@ describe('LocalBackend repo-id collisions (#2054)', () => {
(listRegisteredRepos as any).mockResolvedValue(noRemote);
await backend.init();
const listed = await backend.callTool('list_repos', {});
const listed = await backend.listRepos();
expect(listed).toHaveLength(2); // both present, not collapsed
for (const e of listed) {
expect(e.remoteUrl).toBeUndefined();
@ -1782,14 +1814,14 @@ describe('LocalBackend.listRepos', () => {
it('returns empty array when no repos', async () => {
setupNoRepos();
await backend.init();
const repos = await backend.callTool('list_repos', {});
const repos = await backend.listRepos();
expect(repos).toEqual([]);
});
it('returns repo metadata', async () => {
setupSingleRepo();
await backend.init();
const repos = await backend.callTool('list_repos', {});
const repos = await backend.listRepos();
expect(repos).toHaveLength(1);
expect(repos[0]).toEqual(
expect.objectContaining({
@ -1804,13 +1836,272 @@ describe('LocalBackend.listRepos', () => {
it('re-reads registry on each listRepos call', async () => {
setupSingleRepo();
await backend.init();
await backend.callTool('list_repos', {});
await backend.callTool('list_repos', {});
await backend.listRepos();
await backend.listRepos();
// listRegisteredRepos called: once in init, once per listRepos
expect(listRegisteredRepos).toHaveBeenCalledTimes(3);
});
});
// ─── list_repos pagination (#2119) ─────────────────────────────────────
describe('parseListReposPagination', () => {
const opts = { defaultLimit: 50, maxLimit: 200 };
it('applies defaults when nothing is supplied', () => {
expect(parseListReposPagination(undefined, opts)).toEqual({ limit: 50, offset: 0 });
expect(parseListReposPagination({}, opts)).toEqual({ limit: 50, offset: 0 });
});
it('accepts valid integer limit/offset', () => {
expect(parseListReposPagination({ limit: 10, offset: 20 }, opts)).toEqual({
limit: 10,
offset: 20,
});
});
it('rejects a limit above the maximum (does not silently clamp)', () => {
expect(() => parseListReposPagination({ limit: 201 }, opts)).toThrow(/limit/);
expect(() => parseListReposPagination({ limit: 99999 }, opts)).toThrow(/limit/);
});
it('accepts a valid in-range limit, including the boundary', () => {
expect(parseListReposPagination({ limit: 200 }, opts).limit).toBe(200);
expect(parseListReposPagination({ limit: 199 }, opts).limit).toBe(199);
});
it('rejects malformed limit values', () => {
for (const bad of [0, -5, 1.5, NaN, Infinity, '5', null, true, {}]) {
expect(() => parseListReposPagination({ limit: bad as any }, opts)).toThrow(/limit/);
}
});
it('rejects malformed offset values', () => {
for (const bad of [-1, 2.5, NaN, Infinity, '0', null, false]) {
expect(() => parseListReposPagination({ offset: bad as any }, opts)).toThrow(/offset/);
}
});
});
describe('LocalBackend.listReposPage / callTool list_repos pagination (#2119)', () => {
let backend: LocalBackend;
// Build N registry entries with unique, lexically-ordered names + paths and
// no remoteUrl (so no sibling grouping). Zero-padding makes lexical order
// equal numeric order, so page boundaries are predictable.
const id = (i: number) => `repo-${String(i).padStart(4, '0')}`;
const makeRepoEntries = (count: number) =>
Array.from({ length: count }, (_, i) => ({
...MOCK_REPO_ENTRY,
name: id(i),
path: `/tmp/repos/${id(i)}`,
storagePath: `/tmp/repos/${id(i)}/.gitnexus`,
}));
beforeEach(async () => {
vi.clearAllMocks();
platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(true);
backend = new LocalBackend();
});
it('default page caps a large registry and reports continuation metadata', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', {});
expect(page.repositories).toHaveLength(50);
expect(page.pagination).toEqual({
total: 437,
limit: 50,
offset: 0,
returned: 50,
hasMore: true,
nextOffset: 50,
});
// First page starts at the first repo in deterministic order.
expect(page.repositories[0].name).toBe(id(0));
});
it('limit controls the page size', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 100 });
expect(page.repositories).toHaveLength(100);
expect(page.pagination.limit).toBe(100);
expect(page.pagination.nextOffset).toBe(100);
});
it('offset selects a middle page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 50, offset: 50 });
expect(page.repositories[0].name).toBe(id(50));
expect(page.repositories[49].name).toBe(id(99));
// Assert total + limit too (a total miscalculation at non-zero offset would
// otherwise slip past this targeted middle-page test).
expect(page.pagination).toEqual({
total: 437,
limit: 50,
offset: 50,
returned: 50,
hasMore: true,
nextOffset: 100,
});
});
it('returns the final partial page with hasMore=false and no nextOffset', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 50, offset: 400 });
expect(page.repositories).toHaveLength(37); // 437 - 400
expect(page.pagination.returned).toBe(37);
expect(page.pagination.hasMore).toBe(false);
expect(page.pagination).not.toHaveProperty('nextOffset');
});
it('limit larger than the remaining count returns only the remaining entries', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 200, offset: 400 });
expect(page.repositories).toHaveLength(37);
expect(page.pagination.hasMore).toBe(false);
});
it('offset equal to total returns an empty page (total preserved)', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { offset: 437 });
expect(page.repositories).toHaveLength(0);
expect(page.pagination).toMatchObject({ total: 437, returned: 0, hasMore: false });
expect(page.pagination).not.toHaveProperty('nextOffset');
});
it('offset beyond total returns an empty page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { offset: 1000 });
expect(page.repositories).toHaveLength(0);
expect(page.pagination).toMatchObject({
total: 437,
offset: 1000,
returned: 0,
hasMore: false,
});
});
it('accepts a negative-zero offset (treated as the first page)', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { limit: 5, offset: -0 });
expect(page.repositories[0].name).toBe(id(0));
expect(page.pagination.returned).toBe(5);
// -0 is accepted (not rejected) and behaves as offset 0 (=== treats them equal).
expect(page.pagination.offset === 0).toBe(true);
});
it('accepts a MAX_SAFE_INTEGER offset and returns an empty page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
const page = await backend.callTool('list_repos', { offset: Number.MAX_SAFE_INTEGER });
expect(page.repositories).toHaveLength(0);
expect(page.pagination).toMatchObject({ total: 437, returned: 0, hasMore: false });
expect(page.pagination).not.toHaveProperty('nextOffset');
});
it('returns the full set with metadata when everything fits on one page', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(3));
await backend.init();
const page = await backend.callTool('list_repos', {});
expect(page.repositories).toHaveLength(3);
expect(page.pagination).toEqual({
total: 3,
limit: 50,
offset: 0,
returned: 3,
hasMore: false,
});
});
it('rejects a limit above the maximum through the real callTool path', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(437));
await backend.init();
await expect(backend.callTool('list_repos', { limit: 99999 })).rejects.toThrow(/limit/);
// A request at the documented maximum is still accepted.
const page = await backend.callTool('list_repos', { limit: 200 });
expect(page.repositories).toHaveLength(200);
expect(page.pagination.limit).toBe(200);
expect(page.pagination.hasMore).toBe(true);
});
it('rejects malformed limit/offset through the real callTool path', async () => {
(listRegisteredRepos as any).mockResolvedValue(makeRepoEntries(3));
await backend.init();
await expect(backend.callTool('list_repos', { limit: 0 })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { limit: -5 })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { limit: 1.5 })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { limit: 'all' as any })).rejects.toThrow(/limit/);
await expect(backend.callTool('list_repos', { offset: -1 })).rejects.toThrow(/offset/);
await expect(backend.callTool('list_repos', { offset: 2.5 })).rejects.toThrow(/offset/);
});
it('traverses every repository exactly once across pages (the #2119 guarantee)', async () => {
const entries = makeRepoEntries(437);
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const collected: string[] = [];
let offset = 0;
const limit = 50;
// Hard cap iterations to avoid an infinite loop if hasMore were ever wrong.
for (let guard = 0; guard < 100; guard++) {
const page = await backend.callTool('list_repos', { limit, offset });
collected.push(...page.repositories.map((r: any) => r.path));
expect(page.pagination.total).toBe(437);
if (!page.pagination.hasMore) break;
offset = page.pagination.nextOffset;
}
expect(collected).toHaveLength(437);
expect(new Set(collected).size).toBe(437); // no duplicates
expect(new Set(collected)).toEqual(new Set(entries.map((e) => e.path))); // exact set
});
it('orders pages deterministically by name then path, stable across calls', async () => {
// Scrambled input order; two entries deliberately SHARE a name (collision)
// and must be tie-broken by path, never collapsed.
const entries = [
{ ...MOCK_REPO_ENTRY, name: 'zeta', path: '/tmp/z', storagePath: '/tmp/z/.gitnexus' },
{ ...MOCK_REPO_ENTRY, name: 'shared', path: '/tmp/b', storagePath: '/tmp/b/.gitnexus' },
{ ...MOCK_REPO_ENTRY, name: 'Alpha', path: '/tmp/a', storagePath: '/tmp/a/.gitnexus' },
{ ...MOCK_REPO_ENTRY, name: 'shared', path: '/tmp/a2', storagePath: '/tmp/a2/.gitnexus' },
];
(listRegisteredRepos as any).mockResolvedValue(entries);
await backend.init();
const first = await backend.callTool('list_repos', {});
const order = first.repositories.map((r: any) => `${r.name}@${r.path}`);
// lower-cased name primary (Alpha < shared < zeta), path tie-break for the
// two "shared" entries (/tmp/a2 < /tmp/b).
expect(order).toEqual(['Alpha@/tmp/a', 'shared@/tmp/a2', 'shared@/tmp/b', 'zeta@/tmp/z']);
expect(first.repositories).toHaveLength(4); // collision not collapsed
// Re-listing yields identical page boundaries.
const second = await backend.callTool('list_repos', {});
expect(second.repositories.map((r: any) => `${r.name}@${r.path}`)).toEqual(order);
});
});
// ─── Cypher LadybugDB not ready ────────────────────────────────────────
describe('cypher tool LadybugDB not ready', () => {

View file

@ -414,22 +414,70 @@ describe('formatDetectChangesResult', () => {
// ─── formatListReposResult ───────────────────────────────────────────
describe('formatListReposResult', () => {
it('handles empty/null input', () => {
expect(formatListReposResult([])).toBe('No indexed repositories.');
expect(formatListReposResult(null)).toBe('No indexed repositories.');
it('handles an empty page (no pagination)', () => {
expect(formatListReposResult({ repositories: [] })).toBe('No indexed repositories.');
});
it('formats repo list', () => {
const result = formatListReposResult([
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
stats: { nodes: 100, edges: 200, processes: 10 },
},
]);
it('formats a repo list (no pagination → no footer)', () => {
const result = formatListReposResult({
repositories: [
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: { nodes: 100, edges: 200, processes: 10 },
},
],
});
expect(result).toContain('Indexed repositories');
expect(result).toContain('my-project');
expect(result).toContain('100 symbols');
expect(result).not.toContain('Showing'); // no pagination → no footer
});
it('formats a paginated { repositories, pagination } result with a continuation footer', () => {
const result = formatListReposResult({
repositories: [
{
name: 'my-project',
path: '/home/user/my-project',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: { nodes: 100, edges: 200, processes: 10 },
},
],
pagination: { total: 437, limit: 50, offset: 0, returned: 1, hasMore: true, nextOffset: 50 },
});
expect(result).toContain('Indexed repositories');
expect(result).toContain('my-project');
expect(result).toContain('Showing 1 of 437');
expect(result).toContain('offset 50'); // continuation hint
});
it('formats the final page (hasMore false) without a continuation hint', () => {
const result = formatListReposResult({
repositories: [
{
name: 'only',
path: '/p/only',
indexedAt: '2024-01-01',
lastCommit: 'abc1234',
stats: {},
},
],
pagination: { total: 1, limit: 50, offset: 0, returned: 1, hasMore: false },
});
expect(result).toContain('Showing 1 of 1');
expect(result).not.toContain('More available');
});
it('reports an empty page using pagination metadata', () => {
const result = formatListReposResult({
repositories: [],
pagination: { total: 437, limit: 50, offset: 1000, returned: 0, hasMore: false },
});
expect(result).toContain('No repositories on this page');
expect(result).toContain('437');
});
});

View file

@ -8,7 +8,11 @@
* - Optional repo parameter is present on tools that need it
*/
import { describe, it, expect } from 'vitest';
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.js';
import {
GITNEXUS_TOOLS,
LIST_REPOS_DEFAULT_LIMIT,
LIST_REPOS_MAX_LIMIT,
} from '../../src/mcp/tools.js';
const GROUP_TOOLS = new Set(['group_list', 'group_sync']);
const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
@ -129,10 +133,34 @@ describe('GITNEXUS_TOOLS', () => {
expect(detectTool.inputSchema.required).toEqual([]);
});
it('list_repos tool has no parameters', () => {
it('list_repos tool exposes optional limit/offset pagination params', () => {
const listTool = GITNEXUS_TOOLS.find((t) => t.name === 'list_repos')!;
expect(Object.keys(listTool.inputSchema.properties)).toHaveLength(0);
const props = listTool.inputSchema.properties;
expect(props.limit).toBeDefined();
expect(props.limit.type).toBe('integer');
expect(props.offset).toBeDefined();
expect(props.offset.type).toBe('integer');
// Pagination is opt-in: zero-arg callers must still be valid.
expect(listTool.inputSchema.required).toEqual([]);
// No `repo` param on list_repos (it lists all repos).
expect(props.repo).toBeUndefined();
// Description must teach an LLM to page through every repository.
expect(listTool.description.toLowerCase()).toContain('paginat');
expect(listTool.description).toContain('nextOffset');
expect(listTool.description).toContain('hasMore');
});
it('list_repos schema bounds match the exported pagination constants', () => {
const listTool = GITNEXUS_TOOLS.find((t) => t.name === 'list_repos')!;
const { limit, offset } = listTool.inputSchema.properties;
expect(limit.minimum).toBe(1);
expect(limit.maximum).toBe(LIST_REPOS_MAX_LIMIT);
expect(limit.default).toBe(LIST_REPOS_DEFAULT_LIMIT);
expect(offset.minimum).toBe(0);
expect(offset.default).toBe(0);
// Sane, documented bounds (guards against accidental constant drift).
expect(LIST_REPOS_DEFAULT_LIMIT).toBeLessThanOrEqual(LIST_REPOS_MAX_LIMIT);
expect(LIST_REPOS_DEFAULT_LIMIT).toBeGreaterThan(0);
});
it('per-repo tools have optional repo parameter for backend selection', () => {