mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
* 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:
parent
bd90d5bf90
commit
4682a477d8
13 changed files with 737 additions and 56 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_')) {
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue