From 4682a477d8410bdf1355e90fabb557269f34ebed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Tue, 9 Jun 2026 19:59:54 +0100 Subject: [PATCH] feat(mcp): paginate list_repos to avoid client token truncation (#2119) (#2120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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> 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../skills/gitnexus/gitnexus-guide/SKILL.md | 33 +- README.md | 2 +- .../skills/gitnexus-guide/SKILL.md | 33 +- gitnexus/README.md | 2 +- gitnexus/skills/gitnexus-guide.md | 33 +- gitnexus/src/cli/eval-server.ts | 33 +- gitnexus/src/mcp/local/local-backend.ts | 149 +++++++- gitnexus/src/mcp/server.ts | 2 +- gitnexus/src/mcp/tools.ts | 34 +- .../integration/mcp/server-startup.test.ts | 49 ++- gitnexus/test/unit/calltool-dispatch.test.ts | 317 +++++++++++++++++- gitnexus/test/unit/eval-formatters.test.ts | 72 +++- gitnexus/test/unit/tools.test.ts | 34 +- 13 files changed, 737 insertions(+), 56 deletions(-) diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md index b81900b5e..cacc4e886 100644 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -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 diff --git a/README.md b/README.md index 2cb78cc30..cec967931 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md index b81900b5e..cacc4e886 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md @@ -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 diff --git a/gitnexus/README.md b/gitnexus/README.md index 8c3349037..d6ea20641 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -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 | diff --git a/gitnexus/skills/gitnexus-guide.md b/gitnexus/skills/gitnexus-guide.md index b81900b5e..a54337879 100644 --- a/gitnexus/skills/gitnexus-guide.md +++ b/gitnexus/skills/gitnexus-guide.md @@ -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 diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index f28225e0c..b2dc85dc0 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -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 "" 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 ''; } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index ff566ddd2..6a9fb1536 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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>` 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 = new Map(); private contextCache: Map = 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 { 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 { 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_')) { diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index b2bd5530f..d4a7c58aa 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -44,7 +44,7 @@ function getNextStepHint(toolName: string, args: Record | 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: ""${repoParam}}) to see categorized refs and process participation.`; diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 6ee8b5488..e08cdc12d 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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: [], }, }, diff --git a/gitnexus/test/integration/mcp/server-startup.test.ts b/gitnexus/test/integration/mcp/server-startup.test.ts index 0660200f6..bc3e214c6 100644 --- a/gitnexus/test/integration/mcp/server-startup.test.ts +++ b/gitnexus/test/integration/mcp/server-startup.test.ts @@ -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. diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 422c67548..b87d46b7d 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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', () => { diff --git a/gitnexus/test/unit/eval-formatters.test.ts b/gitnexus/test/unit/eval-formatters.test.ts index f767f9ada..1ef5c950b 100644 --- a/gitnexus/test/unit/eval-formatters.test.ts +++ b/gitnexus/test/unit/eval-formatters.test.ts @@ -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'); }); }); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 7bfdded45..3cf22d188 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -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', () => {