diff --git a/README.md b/README.md index ceb4b3e0..98a49d5e 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ broader open-source ecosystem, not just model memory or local repo context: |---|---|---| | Tool orientation | `quick_start` | — | | Code examples | `get_example` | `githits example` | -| Code navigation | `search`, `search_status`, `code_files`, `code_grep` | `githits search`, `githits search-status`, `githits code ...` | -| Documentation discovery | `docs_list` | `githits docs list` | +| Inventory browsing | `list` | `githits list` | +| Code and documentation search | `search`, `search_status`, `code_grep` | `githits search`, `githits search-status`, `githits code grep` | | Read source files or documentation sections | `read` | `githits read [path]` | | Grep source and hosted documentation together | CLI only (MCP migration pending) | `githits grep ` | | Package inspection | `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, `pkg_upgrade_review` | `githits pkg ...` | diff --git a/changes/unified-list-mcp.changed.md b/changes/unified-list-mcp.changed.md new file mode 100644 index 00000000..96a06eff --- /dev/null +++ b/changes/unified-list-mcp.changed.md @@ -0,0 +1,13 @@ +--- +"githits": minor +"@githits/mcp": minor +--- + +- **Unify MCP inventory browsing** - Replace the callable `code_files` and + `docs_list` tools with one `list` tool for package, repository, and explicit + site inventories; its description retains both retired names for stale tool + searches, while legacy grouped CLI commands remain available. Hosts must + provide the now-required `McpToolServices.listService`; `ListService` and + `ListServiceImpl` are exported from `@githits/mcp/client`. Custom endpoints + must implement `Query.list` for the unified inventory service. Default text + includes the opaque `after` continuation when more entries are available. diff --git a/docs/implementation/TOOL_GUARDRAILS.md b/docs/implementation/TOOL_GUARDRAILS.md index e05faca0..b826f4d2 100644 --- a/docs/implementation/TOOL_GUARDRAILS.md +++ b/docs/implementation/TOOL_GUARDRAILS.md @@ -129,12 +129,12 @@ maintainer-controlled content: - `pkg_info` — registry description, repository topics and recent release notes - `pkg_changelog` — release-notes body - `pkg_upgrade_review` — release-note excerpts and package deprecation text -- `read` and `docs_list` — repo READMEs and crawled docs +- `list` and `read` — repository documentation paths and crawled docs pages - `read` and `code_grep` — repo source code (comments + strings) - `search` — multi-source search snippets - `get_example` — backend-synthesized examples -Other tools (`quick_start`, `pkg_deps`, `code_files`, `search_status`) have no third-party prose surface or attacker +Other tools (`quick_start`, `pkg_deps`, `search_status`) have no third-party prose surface or attacker control and need no per-tool addendum. The shared posture is available to plain MCP agents after they call `quick_start`; a loaded `githits-mcp` skill already carries the stable posture. The runtime-only local appendices are not embedded diff --git a/docs/implementation/cli-commands.md b/docs/implementation/cli-commands.md index fac28f7e..d3fabf95 100644 --- a/docs/implementation/cli-commands.md +++ b/docs/implementation/cli-commands.md @@ -2,7 +2,7 @@ ## Purpose -The CLI exposes setup/auth commands, `doctor`, `example`, top-level indexed `search` / `search-status`, `read`, `list`, `grep`, and the `code`, `docs`, and `pkg` command groups by default. `resolve` and `code diff` are experimental, host-config-gated commands. MCP-parity commands share business logic with the MCP tools through the same service interfaces and shared utilities. Unified search shares its presentation model and text formatter with MCP; `list` uses the shared request, result, error, and path-only text helpers, while MCP tool registration remains a later increment. +The CLI exposes setup/auth commands, `doctor`, `example`, top-level indexed `search` / `search-status`, `read`, `list`, `grep`, and the `code`, `docs`, and `pkg` command groups by default. `resolve` and `code diff` are experimental, host-config-gated commands. MCP-parity commands share business logic with the MCP tools through the same service interfaces and shared utilities. Unified search shares its presentation model and text formatter with MCP; `list` uses the same request, result, error, and path-only text helpers on both surfaces. ## Experimental CLI commands @@ -996,7 +996,8 @@ Each command follows this pattern: | Shared Module | Used By | |---|---| | `GitHitsService` (via container) | `example` and always-on MCP tools | -| `CodeNavigationService` (via container) | top-level unified `search` / `search-status`, MCP indexed-search tools (`search`, `search_status`, `code_files`, `code_grep`), and the `githits code` command group | +| `CodeNavigationService` (via container) | top-level unified `search` / `search-status`, MCP indexed-search tools (`search`, `search_status`, `code_grep`), and the `githits code` command group | +| `ListService` (via container) | top-level CLI and MCP `list` against package, repository, or explicit site inventories | | `ReadService` (via container) | compact top-level `read` and advertised MCP `read`, backed by one `Query.read` request | | `requireAuth()` from `packages/mcp/src/shared/require-auth.ts` | all CLI commands and auth-required MCP tool handlers | diff --git a/docs/implementation/config.md b/docs/implementation/config.md index c6a94041..ba282a15 100644 --- a/docs/implementation/config.md +++ b/docs/implementation/config.md @@ -74,7 +74,7 @@ The container (`src/container.ts`) resolves authentication in priority order: | `/search` | Full access | Full access | Blocked | | `/functions/v1/settings/me` | Full access | Full access | Blocked | -Package/source access uses the OSS service URL selected by `GITHITS_ENV` unless `GITHITS_CODE_NAV_URL` overrides it. MCP registration for `search`, `search_status`, `docs_*`, `pkg_*`, `code_files`, `read`, and `code_grep` is always on; CLI registration for top-level `search` / `search-status` / `read` / `list` / `grep` plus the `githits code`, `githits pkg`, and `githits docs` groups is also always on. +Package/source access uses the OSS service URL selected by `GITHITS_ENV` unless `GITHITS_CODE_NAV_URL` overrides it. MCP registration for `search`, `search_status`, `list`, `read`, `code_grep`, and `pkg_*` is always on; CLI registration for top-level `search` / `search-status` / `read` / `list` / `grep` plus the legacy-compatible `githits code`, `githits pkg`, and `githits docs` groups is also always on. ## Environment Variables diff --git a/docs/implementation/mcp-cli-parity.md b/docs/implementation/mcp-cli-parity.md index 97a9aa89..5e533a41 100644 --- a/docs/implementation/mcp-cli-parity.md +++ b/docs/implementation/mcp-cli-parity.md @@ -47,7 +47,7 @@ The dual-surface tools today are: - `get_example` ↔ `githits example` - `search` ↔ `githits search` - `search_status` ↔ `githits search-status` -- `code_files` ↔ `githits code files` +- `list` ↔ `githits list` - `read` with a path ↔ `githits read ` (legacy `code read` retained) - `code_grep` ↔ `githits code grep` - `pkg_info` ↔ `githits pkg info` @@ -55,7 +55,6 @@ The dual-surface tools today are: - `pkg_deps` ↔ `githits pkg deps` - `pkg_changelog` ↔ `githits pkg changelog` - `pkg_upgrade_review` ↔ `githits pkg upgrade-review` -- `docs_list` ↔ `githits docs list` - `read` without a path ↔ `githits read ` (legacy `docs read` retained) - `resolve_target` ↔ `githits resolve` *(config-gated, local-only)* - `code_diff` ↔ `githits code diff` *(config-gated, local-only)* @@ -528,34 +527,35 @@ When a new tool lands with both MCP and CLI surfaces: | `packages/mcp/src/shared/package-dependencies-response.ts` | Lean JSON envelope builder and shared text/terminal formatter for `pkg_deps`. | | `packages/mcp/src/shared/package-changelog-request.ts` | Shared request builder for `pkg_changelog`. | | `packages/mcp/src/shared/package-changelog-response.ts` | JSON envelope builder and shared text/terminal formatter for `pkg_changelog`. | -| `packages/mcp/src/shared/list-files-request.ts` | Shared request builder for `code_files`. | -| `packages/mcp/src/shared/list-files-response.ts` | JSON envelope builder for `code_files`. | +| `packages/mcp/src/shared/list-request.ts` | Shared request builder for unified `list`. | +| `packages/mcp/src/shared/list-response.ts` | Lossless selected-field JSON projection for unified `list`. | +| `packages/mcp/src/shared/list-text.ts` | Shared token-efficient CLI/MCP path formatter. | | `packages/mcp/src/shared/read-file-request.ts` | Shared request builder for `read`. | -| `packages/mcp/src/shared/read-file-response.ts` | JSON envelope builder for `read`. Normalises envelope key to `path` (not `filePath`) so `code_files` -> `read` chains without renames. | +| `packages/mcp/src/shared/read-file-response.ts` | JSON envelope builder for `read`. Normalises envelope key to `path` (not `filePath`) for exact-file follow-ups. | | `packages/mcp/src/shared/grep-repo-request.ts` | Shared request builder for `code_grep`. Exports `GREP_REPO_PATTERN_NOTE` referenced by MCP description, MCP `pattern` describe, and CLI help. | | `packages/mcp/src/shared/grep-repo-response.ts` | JSON envelope builder for `code_grep`. | -| `packages/mcp/src/shared/list-package-docs-request.ts` / `list-package-docs-response.ts` | Shared request and envelope for `docs_list`. | +| `packages/mcp/src/shared/list-files-request.ts` / `list-files-response.ts` | Legacy grouped CLI file-list compatibility helpers. | +| `packages/mcp/src/shared/list-package-docs-request.ts` / `list-package-docs-response.ts` | Legacy grouped CLI documentation-list compatibility helpers. | | `packages/mcp/src/shared/read-package-doc-request.ts` / `read-package-doc-response.ts` | Shared request and envelope for `read`. | | `packages/mcp/src/shared/code-navigation-error-map.ts` | Owns the `INDEXING`, target/file-not-found, and exact-path authority codes shared across all code-nav tools. | | `packages/mcp/src/shared/package-intelligence-error-map.ts` | `mapPackageIntelligenceError` classifier using the shared `MappedError` contract. | | `packages/core-internal/src/services/promote-version-not-found.ts` | Shared helper that promotes generic backend errors with "no matching version" messages into typed `VERSION_NOT_FOUND`. | -| `packages/mcp/src/tools/code-navigation-shared.ts` | Compact-string `codeTargetSchema` + `resolveCodeTarget` for `code_files`, `code_grep`, and the code branch of `read`; `search` has a related string schema that also accepts exact documentation sites. | +| `packages/mcp/src/tools/code-navigation-shared.ts` | Compact-string target parsing retained by `code_grep` and legacy code navigation; `search`, `list`, and `read` use their transport-neutral request boundaries. | | `packages/mcp/src/tools/search.ts` | MCP tool definition for unified `search`. | | `packages/mcp/src/tools/search-status.ts` | MCP tool definition for `search_status`. | | `packages/mcp/src/tools/package-summary.ts` | MCP tool definition for `pkg_info`. | | `packages/mcp/src/tools/package-vulnerabilities.ts` | MCP tool definition for `pkg_vulns`. | | `packages/mcp/src/tools/package-dependencies.ts` | MCP tool definition for `pkg_deps`. | | `packages/mcp/src/tools/package-changelog.ts` | MCP tool definition for `pkg_changelog`. | -| `packages/mcp/src/tools/list-files.ts` | MCP tool definition for `code_files`. | +| `packages/mcp/src/tools/list.ts` | Stable MCP tool definition for unified `list`. | | `packages/mcp/src/tools/read-file.ts` | Code branch of unified `read`; `tools/read.ts` owns the advertised definition. | | `packages/mcp/src/tools/grep-repo.ts` | MCP tool definition for `code_grep`. | -| `packages/mcp/src/tools/list-package-docs.ts` / `read-package-doc.ts` | MCP tool definitions for the docs surface. | | `src/commands/search.ts` | Top-level CLI commands for unified `search` and `search-status`. | -| `src/commands/list.ts` | Top-level CLI `list` command; currently CLI-only until Phase 2 MCP consolidation. | +| `src/commands/list.ts` | Top-level CLI `list` command sharing its contract and formatter with MCP. | | `src/commands/pkg/info.ts` / `vulns.ts` / `deps.ts` / `changelog.ts` | CLI commands for the `pkg` group. | | `src/commands/code/files.ts` / `read.ts` / `grep.ts` | CLI commands for the `code` group. | | `src/commands/docs/list.ts` / `read.ts` | CLI commands for the `docs` group. | -| `src/tools/*-parity.test.ts` | Parity tests; each cites the rule IDs it enforces. | +| `scripts/cli-smoke.ts` | Live CLI/MCP JSON-shape parity fixtures, including package and site `list`. | ## Per-tool notes @@ -747,22 +747,20 @@ section labels remain plain; only the matched keyword and excerpt marker are yellow. Evidence detail and locators remain plain. Words remain sufficient without color, authored punctuation is ASCII, and backend Unicode is preserved. -### `code_files` / `read` / `code_grep` (file-exploration bundle) +### `list` / `read` / `code_grep` (inventory and file-exploration bundle) -`code_files` and `code_grep` reuse `codeTargetSchema` + `resolveCodeTarget` from -`packages/mcp/src/tools/code-navigation-shared.ts`; the code branch of `read` -accepts compact strings only and uses the same resolver. The indexing lifecycle is -shared (see `tools.md` "Indexing lifecycle" section). Parity tests -cover dual addressing, default + explicit filter echoes, INDEXING -error envelope, NOT_FOUND envelope, and INVALID_ARGUMENT with full -envelope shape. +`list` uses the transport-neutral `ListService` and shared request, response, +error, and text helpers. `read` uses `ReadService`; `code_grep` retains compact +target parsing through the code-navigation service. Live parity fixtures cover +package and explicit-site list JSON shapes, while focused tests cover request +normalization, pagination, actions, errors, and text output. -- **`code_files`**: `filter.path_prefix` / `filter.limit` echo only - when explicit. Default `limit: 200` never round-trips. Backend - returns `total` capped at returned count when `hasMore: true`; - terminal formatter renders `N+`. +- **`list`**: literal paths and globs form a union. Selected directories expose + immediate children unless `recursive` expands them; glob depth is + independent. JSON returns backend-authored read/browse actions and an opaque + continuation cursor. Text returns only the source line and paths. - **`read` code branch**: envelope uses `path` (not `filePath`) to match - `code_files.files[].path`. Binary files: `isBinary: true` + + returned list action/path. Binary files: `isBinary: true` + `content` omitted (not `null`). INDEXING details may carry `indexingRef`, `indexingEstimate`, and any backend-provided indexed refs/versions; callers must branch on whichever retry diff --git a/docs/implementation/mcp-tool-annotations.md b/docs/implementation/mcp-tool-annotations.md index 5d60bc0f..8d234706 100644 --- a/docs/implementation/mcp-tool-annotations.md +++ b/docs/implementation/mcp-tool-annotations.md @@ -26,8 +26,7 @@ These annotation choices do not change request or response behavior. | `quick_start` | Return GitHits usage guidance. | | `get_example` | Find canonical examples. | | `search`, `search_status` | Discover indexed evidence and retrieve search progress/results. | -| `code_files`, `code_grep`, `read` | Navigate and read public source. | -| `docs_list`, `read` | Discover and retrieve public documentation. | +| `list`, `code_grep`, `read` | Browse, search, and read public source or documentation inventories. | | `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, `pkg_upgrade_review` | Retrieve and compute package facts and upgrade evidence. | | Local experimental `research`, `resolve_target`, `code_diff` | Generate cited answers, resolve targets, and compare source versions. | diff --git a/docs/implementation/tools.md b/docs/implementation/tools.md index 2dad6ecd..57b585cc 100644 --- a/docs/implementation/tools.md +++ b/docs/implementation/tools.md @@ -125,10 +125,12 @@ Use the tools in these roles: - **Exact source matching:** Use `code_grep` when the literal, regex, identifier, or call-site pattern is already known. It returns deterministic, paginated matches. Use `search` for conceptual discovery, `read` for a - focused matched-file window, and `code_files` for path enumeration. -- **Navigation and documentation:** Use `code_files` to enumerate paths, - `read` to read an exact source window or emitted docs target, and `docs_list` - to browse package pages. + focused matched-file window, and `list` for path enumeration. +- **Navigation and documentation:** Use `list` to browse files or documentation + pages in a package, repository, or explicit site inventory, then `read` an + exact returned file or page. Package and repository targets include their own + documentation files. Search package documentation first and reuse an emitted + explicit `site:` target when hosted documentation needs browsing. The routing guide selects among these tools; selected descriptions and schemas retain callable follow-up mapping. `get_example` is for canonical @@ -155,12 +157,11 @@ see [Unified read](unified-read.md). ## Current Tools -The root CLI has a separate `githits list [paths...]` command backed by -`Query.list`. It lists package/repository source inventories (including -package-local documentation files) or an explicitly targeted `site:` inventory. -The MCP catalog in this document remains on `code_files` and `docs_list` until -the later MCP migration; this CLI command does not change their schemas or -execution. +The CLI and MCP expose the same `list` contract backed by `Query.list`. It +lists package/repository source inventories, including package-local +documentation files, or an explicitly targeted `site:` inventory. Legacy +grouped CLI commands remain available, but their former MCP tools are no longer +callable. | Tool | Parameters | Description | |---|---|---| @@ -168,19 +169,22 @@ execution. | `get_example` | `query`, `language?`, `license_mode?`, `format?` | Find canonical cross-project examples when no single target is the answer. Also use when target-scoped search came up short; verify version-sensitive patterns against source/docs. Markdown includes source provenance, generated references and optional `solution_id`; the language field owns correction guidance. | | `search` | `query`, `target?` (compact string), `targets?` (compact strings), `source?`, `public_only?`, `allow_partial_results?`, `limit?`, `offset?`, `wait_timeout_ms?`, `format?` | Discover relevant docs, code, and symbols in a known public target. Pass `query` and either `target` or `targets`, not both. Constraints belong in `query`; inspect qualifier/source warnings. Continue only with an explicit `searchRef` and `search_status` action. | | `search_status` | `search_ref`, `wait_timeout_ms?`, `format?` | Continue an explicit search reference for progress and results. Pass the returned `searchRef` as `search_ref` only with an explicit continuation action. Partial hits require the original opt-in; terminal or unrecognized statuses are not polled. | -| `docs_list` | `target` (package string), `limit?`, `after?`, `format?` | List package documentation targets for follow-up reads. Packages only, not standalone `site:` targets. Entries retain `docsReadTarget`, compatible `pageId`, and provenance `sourceUrl`. Hosted HTTP(S) targets address mutable current content; repository targets are snapshot-addressed. Exact Go versions accept both `v`-prefixed and unprefixed forms. Repo-backed entries include exact source metadata for `read` when available. Active empty results remain preparation/indexing outcomes rather than becoming “not found”; provisional results retain already-available pages and lifecycle state. | +| `list` | `target`, `paths?`, `recursive?`, `file_types?`, `languages?`, `intents?`, `limit?`, `after?`, `wait_timeout_ms?`, `format?` | List files or documentation pages in one package, repository, or explicit site inventory. Target-relative literal paths and globs form a union for every target. Selected directories show immediate children unless `recursive` expands them; glob depth is independent. Default text is one path per line with direct read and continuation guidance. JSON preserves exact actions and metadata for programmatic consumers. | | `pkg_info` | `target` (unpinned package string), `verbose?`, `format?` | Assess latest package health and adoption: license, downloads, and activity. Requires an unpinned package target and always returns latest; latest-affected and package-wide history counts are distinct. | | `pkg_vulns` | `target` (package string), `min_severity?`, `advisory_scope?`, `include_withdrawn?`, `include_transitive?`, `verbose?`, `format?` | Check current package advisories. Use current evidence, not memory or cutoff disclaimers; distinguish selected-version risk from package history. Transitive evidence is opt-in and adds graph-analysis cost; selected fields define filter/scope limits. | | `pkg_deps` | `target` (package string), `lifecycle?`, `include_importers?`, `include_issues?`, `max_depth?`, `format?` | Inspect what a package depends on, directly or transitively. Direct runtime dependencies are the default; fields opt into other groups, transitive footprint, importer provenance or issue analysis. Public graphs are not application lockfile/reachability evidence. | | `pkg_changelog` | `target`, `limit?`, `omit_bodies?`, `verbose?`, `body_lines?`, `format?` | Find release notes and changelog history for a package. Latest mode caps entries; pin `target` for one selected release; `@from..to` covers a closed interval. Empty selections succeed with no entries. | | `pkg_upgrade_review` | `registry?`, `package_name?`, `current_version?`, `target_version?`, `packages?`, `skip_transitive_security?`, `include_dependency_issues?`, `min_severity?`, `verbose?`, `format?` | Review a package upgrade: vulnerabilities, releases, peers, dependency changes. Reports facts, not upgrade risk or acceptance. Supports a single package or at most 30 batch upgrades. | -| `code_files` | `target` (compact string), `path?`, `path_prefix?`, `globs?`, `extensions?`, `file_types?`, `languages?`, `file_intent?`, `file_intents?`, `exclude_file_intents?`, `exclude_doc_files?`, `exclude_test_files?`, `include_hidden?`, `limit?`, `wait_timeout_ms?`, `format?` | List indexed files and paths in a public repo or package. Returned paths chain into `read.path` or scope `code_grep`; `path_prefix` narrows directory enumeration. `INDEXING` errors expose retry candidates when known. | | `read` | `target` (string), `path?`, `selector?`, `start_line?`, `end_line?`, `wait_timeout_ms?`, `format?` | Pass a code file target + path, an explicit `site:` target + target-relative page path, a compact `target#symbol` or selector, or another emitted docs target to unified backend read; the returned type determines code/docs presentation. Preserve emitted site action values exactly; do not repeat the target's scope in the path. `/` reads the site's landing page. HTTP(S) docs fragments select sections unless explicit bounds override them. Text displays 150/300 lines; exact-file code caps before fetching, while docs JSON keeps the backend selection. The backend applies wait where relevant. See [unified read](unified-read.md). | | `code_grep` | `target` (compact string), `pattern`, `path?`, `path_prefix?`, `globs?`, `extensions?`, `pattern_type?`, `case_sensitive?`, `exclude_doc_files?`, `exclude_test_files?`, `context_lines?`, `context_lines_before?`, `context_lines_after?`, `max_matches?`, `max_matches_per_file?`, `cursor?`, `symbol_fields?`, `wait_timeout_ms?`, `format?` | Find text, regex, or identifier matches in a public repo or package. Results are deterministic and paginated; `max_matches_per_file` defaults to `max_matches`. | -`quick_start`, `get_example`, `search`, `search_status`, `docs_list`, `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, `pkg_upgrade_review`, `code_files`, `read`, and `code_grep` are registered by default. The package/source service URL defaults to the GitHits-managed endpoint and can be overridden via `GITHITS_CODE_NAV_URL` for local development. +`quick_start`, `get_example`, `search`, `search_status`, `list`, `read`, +`code_grep`, `pkg_info`, `pkg_vulns`, `pkg_deps`, `pkg_changelog`, and +`pkg_upgrade_review` are registered by default. The package/source service URL +defaults to the GitHits-managed endpoint and can be overridden via +`GITHITS_CODE_NAV_URL` for local development. -`docs_list`, `pkg_vulns`, `pkg_deps`, and `pkg_changelog` require +`pkg_vulns`, `pkg_deps`, and `pkg_changelog` require `target: "registry:name[@version]"`, for example `npm:express@5.2.1`; omit the pin for latest. `pkg_changelog` also accepts `@from..to` intervals. `pkg_info` requires an unpinned target such as `npm:express`. These tools accept @@ -402,7 +406,7 @@ contributors are not copied onto generic progress targets, and `verbose: true` adds GitHub language/topics/last-pushed, published-version count, download refresh date (when a download count is present), package-wide advisory rows under `Advisory history (all versions)`, and recent changes. `format: "json"` returns a lean payload designed for programmatic consumers and requests the detailed fields. The exact additive fields are top-level `versionCount`, `downloads.refreshedAt`, and `advisoryHistory.total`; `downloads` is retained when it has only `refreshedAt`. Null scalars are omitted, and empty blocks/arrays are omitted. `vulnerabilities` is emitted whenever the backend reports a numeric latest-version count, including `total: 0`; its `affectsLatest` boolean and package-wide `recent` advisory rows retain their existing meanings. Recent advisory severity values include a CVSS-banded `severityLabel` (`critical` ≥9, `high` ≥7, `medium` ≥4, else `low`) for agent convenience. Compact/default requests select `allVulnerabilityCount` unconditionally; `versionCount` and `downloadsRefreshedAt` are selected only when `includeVerboseFields` is true. -**No quickstart.** `pkg_info` intentionally does not expose install commands or usage snippets. Those values are package-manager-specific and not verified enough for dependency evaluation. Use `docs_list` and `read`, `search`, or `get_example` when usage guidance is needed. +**No quickstart.** `pkg_info` intentionally does not expose install commands or usage snippets. Those values are package-manager-specific and not verified enough for dependency evaluation. Use `search`, `list`, `read`, or `get_example` when usage guidance is needed. **Validation.** MCP requires `target: z.string()`. The handler trims and parses it with the shared `parsePackageSpec`, then uses `buildPackageSummaryParams`; domain failures produce the same mapped `{error, code, retryable}` envelope as CLI. Missing or non-string targets fail SDK schema validation. @@ -572,9 +576,11 @@ expansion remain unchanged except that dependency-issue locators are capped at five rows per category in default text with an explicit remainder; `verbose` expands them fully. -### `code_files` / `read` / `code_grep` response shapes +### `list` / `read` / `code_grep` response shapes -These three indexed tools share an addressing and lifecycle contract (documented below) and then each projects its own data-first envelope. `code_files`, `code_grep`, and the code branch of `read` accept compact target strings and reuse the same target resolver. +These indexed tools share compact target strings and structured resolution +metadata while projecting purpose-specific envelopes. `list` also accepts +explicit `site:` inventories. The advertised MCP `read` tool uses the required `McpToolServices.readService` dependency. Its compact code and docs branches call `ReadService.read` once, @@ -585,17 +591,34 @@ surfaces remain separate compatibility paths: `githits code read` calls the legacy `fetchCodeContext` root and `githits docs read` calls legacy `getDocPage`; they are not fallback implementations for the compact MCP tool. -**`code_files` envelope**: `{registry?|repoUrl?+gitRef?, total, hasMore, indexedVersion?, resolution?, targetResolution?, files: [{path, name?, language?, fileType?, byteSize?}], hint?, filter?}`. `fileType` values preserve the service vocabulary (`CONFIG`, `SOURCE`, `DOC`, `TEST`). JSON preserves the backend `total`; when `hasMore: true`, text renders the returned count with `+` rather than presenting that value as an exact inventory count. `filter` echoes only explicit caller filters (`path`, `pathPrefix`, `globs`, `extensions`, `fileTypes`, `languages`, file-intent filters, booleans, and `limit`); default limit (200) never round-trips. +**`list` envelope**: `{inventoryKind, requestedTarget, canonicalTarget, +entries: [{kind, path, title?, read?, browse?, language?, fileType?, intent?, +byteSize?, lineCount?, contentHash?}], hasMore, nextCursor, indexedVersion, +resolution?, targetResolution?, codeIndexState, indexingStatus, indexingRef, +availableVersions?, indexingEstimate?, inventoryState, crawlStatus, +coverageState, coverageReason, preparation}`. Compact text emits only a source +line and one path per entry. JSON preserves backend-authored actions and the +opaque cursor; it never fabricates a total or reconstructs locators. -**`read` code envelope**: `{registry?|repoUrl?+gitRef?, path, language?, totalLines?, startLine?, endLine?, content?, isBinary?, hint?, targetResolution?}`. `path` (not `filePath`) so the key matches `code_files.files[].path` and `code_grep.filter.path` when exact-file grep is used. Binary files set `isBinary: true` and **omit** `content` (not `null`); agents branch on the flag. `hint` is emitted only when the MCP span cap actually truncated the response — see "read span cap" below. +The legacy `githits code files` command retains its older +`{registry?|repoUrl?+gitRef?, total, hasMore, ... files}` envelope for CLI +compatibility. It is not an advertised MCP tool. + +**`read` code envelope**: `{registry?|repoUrl?+gitRef?, path, language?, totalLines?, startLine?, endLine?, content?, isBinary?, hint?, targetResolution?}`. `path` (not `filePath`) matches `list.entries[].read.path` and `code_grep.filter.path` when exact-file grep is used. Binary files set `isBinary: true` and **omit** `content` (not `null`); agents branch on the flag. `hint` is emitted only when the MCP span cap actually truncated the response — see "read span cap" below. **`code_grep` envelope**: `{registry?|name?|repoUrl?+gitRef?, pattern, patternType?, caseSensitive?, matches: [{filePath, line, matchStartByte, matchEndByte, lineContent, contextBefore?, contextAfter?, fileContentHash?, fileIntent?, symbol?}], nextCursor?, hasMore, truncatedReason?, filesScanned, filesInScope, binaryFilesSkipped?, filesTooLargeSkipped?, totalMatches, uniqueFilesMatched, indexedVersion?, resolution?, targetResolution?, filter?}`. Default-valued fields (`patternType: literal`, `caseSensitive: false`, zero skipped counters, `truncatedReason: none`) are omitted. `filter` echoes only explicit caller filters. Match entries carry `filePath` so grep output chains directly into `read`. `targetResolution` is additive provenance. It explains requested, resolved-requested, and served artifacts plus `freshness` (`current`, `fallback_recent`, `indexing`, `provisional`, or `unavailable`), `freshnessReason`, `indexingRef`, `availableVersions`, `availableRefs`, and `suggestedRefs`. A `provisional` / `exact_provisional` Discovery result is queryable while indexing continues; code-navigation text uses the exact served identity and `indexingRef` and does not substitute a requested ref. Unified search text-v1 instead keeps internal `indexingRef` and reason codes out of default text while retaining the user-meaningful served identity and bounded alternatives. `availableVersions` and `availableRefs` are already-indexed artifacts that can be queried immediately. `suggestedRefs` are fuzzy upstream candidates and may require indexing before use. Existing `indexedVersion`, `resolution`, and locator fields remain served-identity compatibility fields. Text mode renders actionable notes such as `Using recent indexed snapshot`, `Serving an older indexed snapshot; current target is still being indexed`, `Requested ref is being indexed`, `provisional (still indexing)`, `Fresh target is being indexed`, `Target unavailable`, `queryable now`, or `suggested refs`; a code-navigation indexing note includes the exact `served=` identity whenever results came from a queryable snapshot. JSON mode carries the structured object. A `current` resolution is authoritative on every code-navigation surface and suppresses alternative-target remediation; waited search completion is one case where earlier candidates can remain in structured provenance without becoming warnings. -### Indexing lifecycle (shared across `code_files`, `read`, `code_grep`) +### Indexing and inventory lifecycle -All three code-navigation tools share the same indexing-retry contract. The state can arrive through either an error response or a success sentinel (`codeIndexState: "INDEXING"`), and the service layer collapses both to the same typed `CodeNavigationIndexingError` before the envelope builder runs. Agents therefore never see a `codeIndexState` field in a success envelope; they branch on the error path instead. Discovery `search` / `search_status` may additionally expose `codeIndexState: "PROVISIONAL"` with queryable hits and a `searchRef`; complete-only file/list/grep navigation remains on the existing `INDEXING` error contract. +`read` and `code_grep` share the code-navigation indexing-retry contract. The +state can arrive through either an error response or a success sentinel +(`codeIndexState: "INDEXING"`), and that service layer collapses both to the +same typed `CodeNavigationIndexingError` before the envelope builder runs. +`list` has its own typed error family and preserves selected source/site +lifecycle fields in JSON. Discovery `search` / `search_status` may additionally +expose `codeIndexState: "PROVISIONAL"` with queryable hits and a `searchRef`. **`INDEXING` error envelope**: ```json @@ -682,7 +705,7 @@ dependency, and deploying it. External MCP callers must allow the requested wait plus response headroom; the SDK's default 60-second caller timeout is insufficient for the longest calls. Dev direct-Fly checks do not establish production routing. -**Exact-path authority errors**: `read` / `code_grep` distinguish a missing path (`FILE_NOT_FOUND`) from a path deliberately omitted from the index (`FILE_PATH_EXCLUDED`) and an index whose source-file inventory cannot authoritatively answer the path query (`SOURCE_FILE_INVENTORY_UNKNOWN`). The latter two become stable top-level CLI/MCP codes and preserve `filePath`, optional `exclusionReason`, retryability, and target-resolution metadata. All three preserve the backend message and add surface-native `details.action` guidance for inspecting indexed paths. MCP names `code_files`, `path_prefix`, `read`, and `code_grep`; CLI JSON names `githits code files`, a path-prefix positional, `githits code read`, and `githits code grep --path`. CLI terminal output names `code files`. `read` still supports generic `NOT_FOUND` from older/backend paths, and its structured recovery is likewise rendered with MCP or CLI-native names without classifying unrelated target misses as file errors. +**Exact-path authority errors**: `read` / `code_grep` distinguish a missing path (`FILE_NOT_FOUND`) from a path deliberately omitted from the index (`FILE_PATH_EXCLUDED`) and an index whose source-file inventory cannot authoritatively answer the path query (`SOURCE_FILE_INVENTORY_UNKNOWN`). The latter two become stable top-level CLI/MCP codes and preserve `filePath`, optional `exclusionReason`, retryability, and target-resolution metadata. All three preserve the backend message and add surface-native `details.action` guidance for inspecting indexed paths. MCP recovery uses `list` with a directory path selector; compact CLI recovery uses `githits list /`. Legacy grouped CLI commands retain their own surface-native wording. `read` still supports generic `NOT_FOUND` from older/backend paths, and its structured recovery is likewise rendered without classifying unrelated target misses as file errors. **`read` code span bounds (MCP-only)**: real session traces showed agents requesting 300-600 line windows (and occasional unbounded full-file reads) which dominated context cost, while a later Claude Desktop session showed that a fixed 150-line ceiling can waste context by forcing pagination for a known 248-line file. Calls without `end_line` therefore remain bounded to `MCP_READ_DEFAULT_SPAN` (150 lines), while deliberate explicit ranges may request up to `MCP_READ_MAX_SPAN` (300 lines). Both are defined in `packages/mcp/src/shared/code-navigation-defaults.ts` and enforced before the backend call. @@ -833,7 +856,7 @@ conservative and do not poll. Promoted lifecycle/freshness prose, opaque evidenc text, and the exact notice remain in JSON; parser/query and target-owned constraint facts appear once in text. -**Listing anatomy** (`code_files` text-v1): +**Legacy listing anatomy** (`githits code files` compatibility output): ``` code_files | [+] paths | [path="..."] [path_prefix="..."] [globs=...] [exts=...] [...] @@ -931,7 +954,7 @@ not expose that unused backend field. Client diagnostics and result metadata kee only locators, explicit bounds, range coordinates, and existing provenance; they never store documentation content or credentials as usage details. -**Errors in text mode.** `search` errors render as text in `text-v1` mode: `search | ERROR | code= [| retryable]\n` followed by an indented `details:` block when present. `code_files` and `code_grep` keep errors JSON-formatted in either mode for now — revisit if agent feedback warrants. +**Errors in text mode.** `search` errors render as text in `text-v1` mode: `search | ERROR | code= [| retryable]\n` followed by an indented `details:` block when present. `code_grep` keeps errors JSON-formatted in either mode for now; `list` uses the shared mapped error envelope. ## Quick-start guide @@ -1154,7 +1177,8 @@ See `docs/guidelines/TESTING.md` for the full testing pattern. | `packages/mcp/src/mcp/instructions.ts` | Stable guide builder returned by `quick_start` and copied into the loaded `githits-mcp` skill | | `src/commands/mcp.ts` | CLI stdio startup, request-header mode setup, and TTY setup instructions | | `packages/core-internal/src/services/githits-service.ts` | REST API client for example search | -| `packages/core-internal/src/services/code-navigation-service.ts` | Package/source service client for unified `search`, `search_status`, `code_files`, `read`, and `code_grep` | +| `packages/core-internal/src/services/code-navigation-service.ts` | Package/source service client for unified `search`, `search_status`, and legacy/grep navigation | +| `packages/core-internal/src/services/list-service.ts` | Transport-neutral unified `list` client for package, repository, and site inventories | ## Related Documentation diff --git a/docs/implementation/unified-list.md b/docs/implementation/unified-list.md index 23295cec..21477bd1 100644 --- a/docs/implementation/unified-list.md +++ b/docs/implementation/unified-list.md @@ -1,13 +1,16 @@ -# Unified list foundation +# Unified list ## Purpose and delivery state This document records the client implementation for the backend's unified -`Query.list` inventory. The root CLI now exposes `githits list`; the MCP catalog -still exposes its existing `code_files` and `docs_list` tools until Phase 2. -Legacy `githits code files` and `githits docs list` execution remains unchanged -for compatibility, with help pointing to the new target model. The new service -does not route through legacy services or fall back to their GraphQL roots. +`Query.list` inventory. The root CLI exposes `githits list`, and the stable MCP +catalog exposes one `list` tool in place of the retired callable `code_files` +and `docs_list` tools. The replacement descriptor names both retired tools +after its intent-focused first sentence so stale full-description searches can +discover it. No callable aliases remain. Legacy `githits code files` and +`githits docs list` execution remains available for CLI compatibility. The +unified service does not route through legacy services or fall back to their +GraphQL roots. ## Contract and ownership @@ -48,13 +51,15 @@ response. The backend's opaque cursor is otherwise preserved exactly. - `list-response.ts` copies only the selected camelCase `ListResult` fields. It preserves meaningful `null`s and omitted conditional details, clones nested values, and adds no total, filter echo, or reconstructed action. -- `list-text.ts` defines the one token-efficient format that CLI uses now and - the Phase 2 MCP tool must reuse: `# source ` followed by one - path per line. SOURCE headers use the canonical target, falling back to the - requested target. SITE headers use the shared PAGE action target or the - requested target, preserving the base of emitted relative paths; a broader - canonical site owner remains JSON metadata. ` | more results available` - means another page exists. +- `list-text.ts` defines the one token-efficient format shared by CLI and MCP: + `# source ` followed by one path per line. SOURCE headers use the + canonical target, falling back to the requested target. SITE headers use the + shared PAGE action target or the requested target, preserving the base of + emitted relative paths; a broader canonical site owner remains JSON metadata. + ` | more results available` means another page exists. When the backend + returns a cursor, a dim footer tells CLI callers to rerun with `--after` and + MCP callers to repeat the same list with `after`; both preserve the opaque + cursor exactly. CLI dims this line when color is enabled; MCP emits the same plain text without ANSI. CLI `--silent` omits the header and emits only path lines for piping; an empty inventory then emits no bytes. Source @@ -73,11 +78,13 @@ response. The backend's opaque cursor is otherwise preserved exactly. The core service owns the network and backend contract because it is shared by both surfaces. The MCP shared modules own input normalization, error and result projection, and text because both callers need identical semantics. The root -CLI owns Commander parsing, authentication entry, and the spinner, while its -command delegates list semantics to those shared helpers. -The MCP adapter is a later increment, so current runtime use is CLI-only. -`packages/mcp/src/internal.ts` exports the helpers only through the -workspace-internal boundary; they are not a public MCP client API. +CLI owns Commander parsing, authentication entry, the spinner, and `--silent`. +`packages/mcp/src/tools/list.ts` owns the ten-argument MCP schema and delegates +to the same helpers. `McpToolServices.listService` makes the dependency explicit +for local and request-scoped hosted composition. `@githits/mcp/client` exports +the public `ListService` types and `ListServiceImpl`; the package root does not +export the concrete implementation. Workspace callers can also reach the +shared helpers through `packages/mcp/src/internal.ts`. ## Actions and lifecycle @@ -97,10 +104,11 @@ actions remain authoritative for exceptional URL/query/encoding identities; the client formatter does not reconstruct them from display paths. Continuation uses the returned `nextCursor`; callers do not reuse the previous -cursor or modify its contents. Lossless JSON exposes the cursor, while the -original request supplies the selection that must be replayed. Compact text -does not add a continuation footer. The service itself does not scan pages or -reconstruct inventory client-side. +cursor or modify its contents. Default text exposes it in a surface-native +continuation footer and tells callers to reuse the same target, paths, and +options. Lossless JSON also preserves it for programmatic consumers. Silent +CLI output remains paths-only and therefore omits the footer. The service +itself does not scan pages or reconstruct inventory client-side. SOURCE indexing metadata (`codeIndexState`, `indexingStatus`, `indexingRef`, and detailed resolution data) remains distinct from an empty result in JSON. @@ -114,11 +122,13 @@ for unsupported API or pagination errors. Core service tests cover exact GraphQL variables and selections, compact versus detailed fields, nullable response projection, cursor validation, error classification, and authentication refresh. Shared request and error tests -cover normalization and mapped envelopes. CLI tests cover Commander flags, -package/repository/site forwarding, pagination, compact versus detailed calls, -path rendering, diagnostics, authentication, and the absence of a legacy -service fallback. Response tests cover exact actions, cursors, null fidelity, -and lifecycle combinations. Text tests cover canonical/requested source +cover normalization and mapped envelopes. MCP tests cover the exact descriptor +prefix and compatibility sentence, all ten arguments, text/JSON projection, +errors, cancellation, and package/site follow-up actions. CLI tests cover +Commander flags, package/repository/site forwarding, pagination, compact versus +detailed calls, path rendering, diagnostics, authentication, and the absence of +a legacy service fallback. Response tests cover exact actions, cursors, null +fidelity, and lifecycle combinations. Text tests cover canonical/requested source identity, pagination, dim CLI presentation, path-only rows, directory suffixes, empty results, and control-character escaping. These client tests do not claim to validate backend path/glob @@ -140,7 +150,9 @@ These are UTF-8 output sizes, not tokenizer-specific token counts. The durable text sizes without requiring network access. It also compares the prior compact entry selection (`kind`, `path`, `title`, `read`, `browse`) with the new source `kind`/`path` selection. Compact site text additionally fetches exact -`read.target` and `read.path` values. +`read.target` and `read.path` values. Before the text continuation footer, its +100-entry source/site cases were 3,613/2,119 bytes. The same cases are now +3,702/2,208 bytes, an 89-byte continuation cost (2.5%/4.2%). Authenticated live CLI conformance on 2026-09-26 verified that the hosted endpoint exposes `Query.list` for package, repository, and site targets. The @@ -161,12 +173,16 @@ formatter are aligned with its schema hash authenticated action replay against its production deployment passed on 2026-09-28 for the Express root, a normal page with and without a trailing slash, and the same page through a nested site scope. Package and repository list-to-read -regression checks also passed against production. The permanent CLI smoke now -covers package and site text/JSON listings, paths-only package output, package -continuation, and replaying a site PAGE action through unified `read`. -Phase 2 retains MCP/agent and package-to-site discovery validation. - -Backend PR #2857 corrected target-relative site paths. Dev deployment and a +regression checks also passed against production. The permanent CLI and MCP +smoke suites cover package and site text/JSON listings, CLI paths-only output, +default-text continuation, JSON parity, and replaying exact package and site +actions through unified `read`. Descriptor-only agent workloads cover package/repository +boundaries, directory recursion versus glob depth, continuation, exact-site +browse/read, and package documentation search followed by an emitted explicit +site target. + +Backend PR #2857 corrected target-relative site paths and is deployed to +production. Its earlier dev deployment and a fresh external installation of published `githits@0.23.0` verified Express `en/resources/community` replay (82 lines, 3324 content characters) and scoped `site:reference.langchain.com/python/langchain` paths: `agents/` @@ -183,8 +199,8 @@ Built CLI dev replay on 2026-09-29 verified all four descendant directories, corpus-relative `agents/.../` directories, and Express `en/.../` directories. Replaying the descendant's unchanged `_subagent_transformer/` browse action then `_subagent_transformer/AsyncSubagentRunStream` read action returned -nonempty Markdown with that same deeper target. This is dev evidence; it does -not establish production deployment of the corrected backend contract. +nonempty Markdown with that same deeper target. The later production checks +below verify the deployed corrected contract. The MCP read-path parameter now states the same target-relative contract. Focused schema tests and built-CLI dev replay cover the separate path argument. @@ -226,5 +242,7 @@ page from a directory. | `packages/mcp/src/shared/list-error-map.ts` | Mapping list errors into the shared envelope | | `packages/mcp/src/shared/list-response.ts` | Allowlisted, null-preserving JSON projection | | `packages/mcp/src/shared/list-text.ts` | Shared path-only CLI/MCP text rendering | +| `packages/mcp/src/tools/list.ts` | Stable MCP descriptor, schema, and adapter | +| `packages/mcp/src/client.ts` | Public service types and concrete client export | | `packages/mcp/src/internal.ts` | Workspace-only exports for shared helpers | | `pkgseer-backend/priv/graphql/schema.graphql` | Backend `Query.list` schema source | diff --git a/docs/plans/mcp-tool-surface-simplification.md b/docs/plans/mcp-tool-surface-simplification.md index 976ab828..1096f031 100644 --- a/docs/plans/mcp-tool-surface-simplification.md +++ b/docs/plans/mcp-tool-surface-simplification.md @@ -362,8 +362,9 @@ Navigation and upgrade-review decisions do not block Phase 2b. 3. **Phase 3 — one MCP search-filter language (MERGED):** the six backend-supported inline qualifiers replace their duplicate MCP fields while CLI flags and `public_only` remain. -4. **Phase 4 — essential navigation controls only (PENDING):** `code_files` and - `code_grep` expose one non-overlapping control for each verified caller need. +4. **Phase 4 — essential grep controls only (PENDING):** `code_grep` retains the + smallest set of controls with distinct roles for verified scoping, context, + pagination, and result-diversity needs. 5. **Phase 5 — actionable example-language recovery (MERGED, PR #399):** `get_example` keeps language filtering. Unresolved languages fail before generation and return up to five canonical retry names. `search_language` and @@ -1524,19 +1525,17 @@ catalog-size comparison, live smoke cases, and targeted descriptor evals. No pha was split, reordered, or redesigned in this bookkeeping pass. The outstanding backend name-composition gap and Claude descriptor-auth limitation remain explicit. -### Phase 4: essential navigation controls only +### Phase 4: essential grep controls only **Status:** PENDING PRODUCT DISCUSSION -**Expected outcome:** `code_files` and `code_grep` retain the smallest set of controls -that covers verified enumeration, scoping, context, pagination, and result-diversity -needs. +**Expected outcome:** `code_grep` retains the smallest set of controls that covers +verified scoping, context, pagination, and result-diversity needs. **Assumptions:** CLI may keep additional expert flags when they do not burden MCP. -**Unknowns or product decisions:** Decide singular/plural intent controls, selector -overlap, symmetric/asymmetric context, and total/per-file limits using real call-shape -evidence. +**Unknowns or product decisions:** Decide symmetric/asymmetric context and total/per-file +limits using real call-shape evidence. **Dependencies:** User discussion and call-shape evidence at reorientation. diff --git a/docs/plans/unified-list.md b/docs/plans/unified-list.md index 8eb4ac02..d8d712c5 100644 --- a/docs/plans/unified-list.md +++ b/docs/plans/unified-list.md @@ -2,32 +2,34 @@ ## Status and expected outcome -**Status: IN PROGRESS.** Phase 1A and Phase 1B merged in PR #421 at -`5e541604935f1d7bb030742e2602356b9ef1e88c` on 2026-09-28. PR #422 -previously merged the CLI increment into its stacked base. Phase 2 has not -started; Phase 1 is complete. +**Status: IN PROGRESS.** Phase 1 is complete. PR #421 merged to `main` as +`5e541604935f1d7bb030742e2602356b9ef1e88c` on 2026-09-28. Phase 2 is +implemented, verified, and review-clean on `jlitola/unified-list-mcp` after its +rebase. Deterministic, live, and Codex agent verification pass. -The rebased Phase 1 branch passed 5,044 tests, typecheck, formatting, build, +The merged Phase 1 head passed 5,044 tests, typecheck, formatting, build, 149-step live CLI smoke, 65-step live MCP smoke, 36-step built CLI smoke, and 9-step built MCP registration smoke against production. Authenticated package, repository, and site list/read follow-ups passed. Backend -PR #2817 merged as `518e45d301d0ba3f451ff56034551addc2bfc7fe` and its -earlier `site:` target plus host-relative page path shape deployed to production. +PR #2817 merged as `518e45d301d0ba3f451ff56034551addc2bfc7fe`; backend +PR #2857 subsequently corrected site actions to target-relative paths and is +deployed to production. Live CLI replay passes for the Express site root, a normal page with and without its trailing slash, and the same page through a nested site scope. The -Phase 1 CLI listing and shared site-page reads shipped in `githits@0.23.0`. -Those checks preceded backend PR #2857's target-relative contract verified on -dev and the reproduced CLI directory-rendering defect. The corrective -[site-relative rendering increment](site-relative-path-rendering.md) records -the current contract and dev evidence; newer production deployment is unverified. -This does not claim Phase 2 MCP catalog consolidation or Phase 3 hosted adoption. - -Replace the advertised MCP `code_files` and `docs_list` tools with one `list` -tool, and add the matching top-level `githits list` command. The new surface -browses exactly one package source tree, repository snapshot, or hosted -documentation site, follows familiar path/glob semantics, and paginates with -an opaque cursor. Compact text is a path inventory; lossless JSON retains -backend-authored actions that feed directly into `read` or another `list` call. +client work is on `main`; package publication remains pending. The 0.23.0 +release preparation includes Phase 1 CLI listing and shared site-page reads. +It does not claim Phase 2 MCP catalog consolidation or Phase 3 hosted adoption. + +Replace the callable MCP `code_files` and `docs_list` tools with one `list` +tool, and add the matching top-level `githits list` command. The `list` +description leads with listing intent and retains both retired tool names in a +later compatibility sentence during the client-skill grace period, allowing +full-description tool search from stale guidance to discover the replacement. +The new surface browses exactly one package source tree, repository snapshot, +or hosted documentation site, follows familiar path/glob semantics, and +paginates with an opaque cursor. Compact text is a path inventory; lossless +JSON retains backend-authored actions that feed directly into `read` or another +`list` call. Package and repository inventories contain source files and documentation files together. Hosted pages remain a separate inventory selected by an @@ -75,9 +77,9 @@ The permanent backend documentation establishes these semantics: - Omitted paths browse immediate roots. Literal paths and globs form a union. `*`, `?`, character classes, backslash escaping, and whole-component `**` are supported. Dot-prefixed paths are ordinary inventory entries. -- Source paths are package- or repository-relative. Site paths are logical, - host-qualified paths such as `expressjs.com/en/5x/api/`; emitted browse paths - are the safest selectors and must be replayed unchanged. +- Source paths are package- or repository-relative. Site paths are relative to + the supplied `site:` target. Emitted browse paths are authoritative and must + be replayed unchanged with their supplied target. - Glob depth is independent of recursion. With recursion off, selected directories expose immediate children. With recursion on, selected directories expand to descendant leaves and no directory rows are emitted. @@ -95,8 +97,8 @@ The permanent backend documentation establishes these semantics: and version-pinned; it is unavailable when the backend cannot form a versioned package target. Hosted PAGE actions use the requested `site:` target plus a target-relative path when that pair resolves the stored URL. - `/` addresses the site's landing page. A single non-root trailing slash is - omitted when no active slashless counterpart exists; distinct slash variants remain exact. + `/` addresses the root. A single non-root trailing slash is omitted when no + active slashless counterpart exists; distinct slash variants remain exact. Exceptional origins retain exact-URL actions. - An unprepared source at zero wait returns its typed indexing error. Positive wait can return an `INDEXING` result with empty entries. Sites return active @@ -161,7 +163,8 @@ discover documentation sites, add content search, redesign `code_grep`, remove legacy CLI commands, deploy `pkgseer-backend` or `remote-mcp`, or publish a release. It does not add a hidden-entry flag, prefix mode, client-side full inventory scan, fallback to legacy GraphQL roots, fabricated total, or client -snapshot/cache infrastructure. +snapshot/cache infrastructure. It does not keep callable MCP aliases for +`code_files` or `docs_list`; compatibility is discovery wording on `list`. ## Target contract @@ -169,10 +172,20 @@ snapshot/cache infrastructure. The MCP tool is named `list`. Its first description sentence is: -> List files or documentation pages in a package, repository, or site. +> List files and documentation paths in a known package, repository, or site. -This sentence is under 80 characters and distinguishes enumeration from -content search. The MCP arguments map directly to `Query.list`: +This intent-focused sentence is under 80 characters and distinguishes +enumeration from content search on the standalone deferred-tool selection +surface. A later sentence states exactly: + +> Replaces code_files and docs_list. + +That compatibility sentence preserves both legacy identifiers for +full-description tool search from stale installed skills. Other following +sentences explain package, repository, and explicit-site targets plus call +mechanics. Keep the compatibility sentence through Phase 3; removing it +requires a later grace-period decision based on client-skill adoption and is +outside this plan. The MCP arguments map directly to `Query.list`: | MCP argument | Backend argument | Contract | | --- | --- | --- | @@ -204,6 +217,7 @@ githits list [paths...] --limit --after --wait + -s, --silent --json ``` @@ -223,7 +237,7 @@ Examples: githits list npm:express@5.2.1 githits list npm:express@5.2.1 lib/ -R githits list github:expressjs/express@v5.2.1 '**/*.md' -githits list site:expressjs.com 'expressjs.com/en/5x/api/' +githits list site:expressjs.com 'en/5x/api/' ``` ### Result and output behavior @@ -235,12 +249,10 @@ and indexing fields, and site inventory/crawl/coverage/preparation fields. Nullable fields stay nullable where absence is meaningful; no synthetic total or reconstructed action is added. -Text output starts with `# source ` followed by one unquoted +Text output starts with `# source ` followed by one unquoted path per line. When the backend has another page, the header adds ` | more results available`. -SOURCE headers use the canonical target when available, otherwise the requested -target. SITE headers preserve the emitted path base using the shared PAGE action -target or requested target; a broader canonical owner remains JSON metadata. +If no canonical target is available, the header uses the requested target. CLI dims the header when color is enabled; MCP uses the same text without ANSI. CLI `-s, --silent` omits the header and progress display so stdout contains only path lines for piping; an empty inventory emits no bytes. JSON is @@ -250,20 +262,19 @@ returned file can be read without reconstructing its source identity. Directory paths end in `/`; source files have no prefix. Paths escape controls and backslashes so the line-oriented format stays unambiguous; quotes, spaces, and ordinary Unicode remain literal. The text surface omits titles, -entry kinds, counts, per-entry commands, lifecycle diagnostics, and continuation -commands. For sites, compact projection includes exact `read.target` and +entry kinds, counts, per-entry commands, and lifecycle diagnostics. When a +cursor is available, a footer tells CLI callers to reuse the same list with +`--after` and MCP callers with `after`. For sites, compact projection includes exact `read.target` and `read.path` values. The header reuses a shared site action target, and PAGE -rows render the corresponding target-relative path; `/` is the site's landing -page. DIRECTORY rows preserve their target-relative paths and end in `/`. Directory-only SITE -headers use the requested target, retaining a deeper request's path base while -the canonical owner remains metadata. A meaningful PAGE trailing slash is +rows render the corresponding target-relative path; `/` is the root. DIRECTORY +rows remain relative and end in `/`. A meaningful PAGE trailing slash is preserved when the backend must distinguish coexisting slash variants. Exceptional URL-only PAGE actions render their exact target. If logical PAGE actions disagree on the site target, the header omits follow-up guidance. -The formatter never derives an action from a site display path. Callers that -need the opaque cursor, structured actions, -lifecycle, or metadata use JSON. This keeps one token-efficient text contract -for CLI and MCP. +The formatter never derives an action from a site display path. Default text +contains the cursor and follow-up guidance needed by agents. JSON remains for +programmatic consumers that parse structured actions, lifecycle, or metadata. +This keeps one token-efficient text contract for CLI and MCP. ### Errors and continuation @@ -340,12 +351,16 @@ neither fetches bodies, snippets, or section trees. ## Cross-cutting constraints -- **Compatibility:** remove `code_files` and `docs_list` from the advertised - MCP catalog when `list` lands. Keep `githits code files` and - `githits docs list` on their unchanged legacy services as compatibility - cohorts, but add a concise deprecation pointer to `githits list` in their help - text. Their execution behavior, scope, and pagination remain unchanged; they - are not aliases for `githits list`. +- **Compatibility:** remove `code_files` and `docs_list` from the callable MCP + catalog when `list` lands, without registering aliases. Keep their exact + names in a later `list` description sentence so full-description tool search + from stale client skills can discover the replacement, strengthening the + existing `read` migration pattern. The first sentence and first 80 raw + characters remain focused on natural listing intent. Keep + `githits code files` and `githits docs list` on their unchanged legacy + services as CLI compatibility cohorts, with their existing deprecation + pointers to `githits list`. Their execution behavior, scope, and pagination + remain unchanged; they are not aliases for `githits list`. - **Custom endpoints:** compact `list` requires `Query.list`. Do not silently call deprecated roots when the endpoint lacks it. - **Migration/rollback:** the client package can be rolled back to restore the @@ -385,6 +400,10 @@ Overall unknowns: - Final released package versions and remote deployment timing are unknown and are chosen during authorized release/adoption work. +- The evidence threshold and date for removing `code_files` / `docs_list` from + the `list` description are a later product decision after client-skill + adoption. This plan keeps the compatibility wording through release and + hosted adoption. Open product decisions for Phases 1-3: **none**. The user chose one paths/glob input, separate target inventories, combined files/docs within source targets, @@ -396,154 +415,72 @@ PR #2817 resolves site addressing, including `/` for the root. | Phase | Status | Outcome | | --- | --- | --- | -| 1. Add the shared contract and CLI | **COMPLETE; PR #421 MERGED** | `githits list` browses the backend contract through the tested transport-neutral service and shared formatter. Backend #2817 production conformance and live site action replay pass. | -| 2. Consolidate the MCP surface | **PLANNED; merge dependency satisfied** | The advertised catalog contains `list` instead of `code_files` and `docs_list`, and agent guidance routes package/repository/site browsing and follow-up actions correctly. | +| 1. Add the shared contract and CLI | **COMPLETE; merged as `5e54160`** | `githits list` browses the backend contract through a tested transport-neutral service and shared formatter. Backend #2817 production conformance and live site action replay pass. | +| 2. Consolidate the MCP surface | **IN PROGRESS; REVIEW-CLEAN** | The callable catalog contains `list` instead of `code_files` and `docs_list`; the replacement description retains both legacy names, and current guidance routes package/repository/site browsing and follow-up actions correctly. Deterministic, live, and Codex agent verification pass. | | 3. Release and hosted adoption | **PLANNED; authorization/deployment dependent** | Published CLI and hosted MCP expose the same unified list contract, and live list-to-read/list-to-list paths pass against the deployed backend. | -## Phase 1 detailed plan — shared contract and CLI - -**Status:** COMPLETE; increments 1A and 1B merged through PR #421. Backend -PR #2817 is deployed to production and live site action replay passes. - -**Expected outcome:** the root CLI implements the committed backend contract -through a transport-neutral `ListService`. `githits list` can browse all three -target kinds, continue pages, render a path-only text inventory, and preserve -exact read/browse actions in JSON. Existing -grouped CLI commands keep their legacy execution paths and point users toward -the new command. - -**Assumptions:** backend PR #2817's SDL is stable for this increment; existing -endpoint, token refresh, headers, diagnostics, error envelopes, and formatter -conventions remain reusable. - -**Unknowns or product decisions:** none. - -**Dependencies:** backend PR #2817 with schema hash -`sha256:cbddb30fa7d0`; current unified `read` implementation. - -**Delivery split:** implementation reached about 1,700 changed non-test lines -after the transport and shared-contract work, before CLI wiring. Per the -repository size rule and the split point below, Phase 1 is delivered as two -stacked review increments: **1A** owns the private core transport plus shared -request/result/error/formatter contract; **1B** owns the top-level CLI, -container/help/smoke integration, user-facing documentation, and release -fragment. This is a review boundary only: Phase 1 is complete only after both -increments pass their acceptance checks and merge. +## Phase 1 completion record — shared contract and CLI -### Ordered implementation - -1. **Increment 1A:** add `packages/core-internal/src/services/list-service.ts` with explicit - request/result/action/lifecycle interfaces, Zod response validation, the - conditional compact/detailed `Query.list` document, variable omission rules, - token refresh, diagnostics, and typed transport/HTTP/GraphQL/malformed - errors. Model `SOURCE_INVENTORY_SCOPE_UNAVAILABLE` extension `repo_url` and - `commit_sha` as optional typed recovery fields. Define a list-specific error - family that reuses existing common service primitives. Reuse - existing navigation selection fragments only for fields consumed by list - output/recovery. Export it from the private core index for root CLI use. -2. **Increment 1A:** add shared list modules under `packages/mcp/src/shared/` for request - validation, result projection, mapped errors/recovery actions, and text - formatting, and export those CLI-facing helpers through the workspace-only - `packages/mcp/src/internal.ts` (`@githits/mcp/internal`) boundary. Cover - explicit false, empty arrays/cursors, UTF-8 path byte bounds, action - preservation, nullable package canonical/browse values, - the sanctioned pinned `repo_url@commit_sha` alternative only when both - values exist, cursor/request validation guidance based on presence of - `after`, and a shared path-only text format without branching on backend - message text. -3. **Increment 1B:** add `src/commands/list.ts`, export/register it eagerly, construct - `ListService` in both container auth paths, and use the shared formatter. - Add deprecation pointers to the old grouped command help while leaving their - actions, services, flags, and output untouched. `code files` points to - `githits list`; `docs list` states that hosted-page browsing now requires - `githits list site:`, while package-local documentation files - remain available from the package target. -4. **Across 1A and 1B:** add focused core-service, shared-builder/formatter/error, CLI action, - container, registration, and CLI smoke coverage. Admit emitted site target - plus page path actions through the existing unified CLI and MCP read adapters. - Start - `docs/implementation/unified-list.md`, update the active CLI/tools/config - documentation, and add a `githits: minor`, `@githits/mcp: minor` fragment. - -### Required Phase 1 coverage - -- Wire tests prove literal/glob path arrays, ordered unions, explicit - `recursive: false`, source filters, `after`, and bounds pass through exactly; - empty arrays/cursors are omitted. They do not re-test backend matching. -- Package, repository, and site response fixtures prove projection of exact - read/browse actions, nullable canonical/browse values, and simultaneous - landing-page actions. Text fixtures prove path-only rendering of - package-relative source paths, backend-authored target-relative site read - paths, exceptional URL actions, and relative site directories. They do not - claim to prove backend scope or hierarchy. -- Pagination projection requires a nonempty cursor with `hasMore: true`, never - infers a total, and preserves opaque cursor bytes. Any `VALIDATION_ERROR` on a - request with `after` renders the two-step restart/correct guidance; the same - code without `after` renders input-correction guidance. -- Source `INDEXING` empty results and site EMPTY/PARTIAL/CAPPED/RUNNING/FAILED - combinations remain distinguishable. Scope-unavailable and unsupported API - errors never invoke legacy list services. Scope-unavailable forms the exact - `@` alternative only when both typed extensions exist; - missing-field fixtures retain the backend message without an action. -- Text and JSON preserve Unicode and encoded paths; text escapes line-breaking - and terminal control characters and appends `/` to directory rows. -- GraphQL wire tests assert exact variables and selected fields for compact and - detailed projections across target/lifecycle fixtures and prove - bodies/content are absent. - -### Verification and acceptance +**Status:** COMPLETE. PR #421 merged to `main` as +`5e541604935f1d7bb030742e2602356b9ef1e88c` on 2026-09-28. -Run the affected tests during development, then complete: - -```text -bun test -bun run typecheck -bun run build -bun run smoke:cli -bun run smoke:mcp -bun run smoke:cli:built -bun run smoke:mcp:built -``` +The merged increment added the transport-neutral list service and shared +request, projection, error, and path-only text contract; registered the +`githits list` CLI; retained legacy grouped CLI execution; and admitted +backend-authored `site:` target plus page-path actions through unified `read`. +The final text contract includes direct read guidance, the explicit +`more results available` pagination cue, and `-s, --silent` paths-only output. -The CLI smoke suites remain useful unauthenticated by verifying auth handling. -Phase 1 is accepted when these checks pass; `githits list` text and JSON match -the shared contract; exact JSON actions and all error/lifecycle shapes work in -fixtures; legacy grouped command execution remains covered; and implementation -code stays below the repository threshold. If implementation approaches 2,000 -changed non-test/documentation lines, stop and split core service/request -projection from CLI/formatter wiring rather than adding mechanism. - -The 2026-09-28 descriptor-only `docs-fragment-read` eval completed with both -Codex and Claude. In neutral discovery both answered without tools. With the -GitHits intent profile, both used `read` and returned the requested section; -Claude also exercised the new `site:` target plus page-path shape while -recovering from a Flask site target that the dev backend did not recognize. -No isolation violations were reported. +The reviewed head passed 5,044 tests, typecheck, formatting, build, the +100-entry list-text size fixture, 149-step production CLI smoke, 65-step +production MCP smoke, 36-step built CLI smoke, and 9-step built MCP +registration smoke. Production checks covered package, repository, and site +list/read follow-ups, package continuation, paths-only output, root and normal +site pages, trailing-slash handling, and nested site scope. PR checks passed on +Ubuntu, Windows, Bun, Node 20, 22, 24, and 26, including public MCP package +validation. Backend PR #2817 is deployed to production; the CLI and MCP package +versions are prepared at 0.23.0, and publication remains pending. ## Phase 2 detailed plan — consolidate the MCP surface -**Status:** PLANNED; PR #421 has merged to `main`, satisfying the recorded -merge dependency. Reorient before implementation. No further product decision -is recorded. - -**Expected outcome:** stdio MCP and the public MCP package advertise one `list` -tool in place of `code_files` and `docs_list`. Its request, output, errors, and -path-only text remain identical to the Phase 1 shared contract. Quick-start and -public skills teach package/repository browsing, explicit site browsing, and -package-to-site discovery. - -**Assumptions:** Phase 1's service/formatter API remains adequate; lossless JSON -is the follow-up surface for exact actions and opaque cursors; the hosted +**Status:** IN PROGRESS; implementation, verification, and review complete. +PR #421 is present on `origin/main` at `5e54160`; no further product decision +is required. + +The 2026-09-28 readiness check confirmed that `origin/main` still registers +`code_files` and `docs_list`, while the public MCP client does not export +`ListService` and `McpToolServices` does not require it. The merged shared list +request, projection, error, and formatter helpers remain available for the new +tool. The plugin-maintenance workflow and all named validation commands exist. +A production package documentation search emitted `site:expressjs.com` in its +default text source summary, validating the planned package-search-to-site-list +route. + +**Expected outcome:** stdio MCP and the public MCP package advertise one +callable `list` tool in place of `code_files` and `docs_list`. Its first +description sentence remains focused on listing intent, while a later sentence +names both retired tools so full-description tool search from stale client +skills can discover the replacement. +Its request, output, errors, and path-only text remain identical to the Phase 1 +shared contract. The stable MCP quick-start skill teaches package/repository +browsing, explicit site browsing, and package-to-site discovery. The public +CLI `githits-code` skill remains on released behavior until Phase 3 adoption. + +**Assumptions:** Phase 1's service/formatter API remains adequate; default text +is the agent follow-up surface for read actions and opaque cursors; the hosted endpoint continues to implement the verified SDL; docs search can expose related explicit `site:` targets, while locally enabled `resolve_target` -remains an additional route for fuzzy or natural names. +remains an additional route for fuzzy or natural names. Installed client +skills may continue naming `code_files` or `docs_list` after release. The +verified first-sentence selection boundary remains focused on listing intent; +full-description tool search receives the exact legacy names. **Unknowns or product decisions:** none. Base endpoint availability was verified on 2026-09-26; the deployed #2817 site-read shape passed Phase 1 live replay on 2026-09-28. No product decision is open. -**Dependencies:** Phase 1 merged; the plugin-maintenance workflow for public -guidance. +**Dependencies:** Phase 1 is merged. The repository-internal +plugin-maintenance workflow governs public guidance changes. ### Ordered implementation @@ -552,18 +489,33 @@ guidance. contract coverage, and outside-root public-package validation. 2. Add `packages/mcp/src/tools/list.ts` with the reviewed ten-argument schema and read-only annotations. Replace `code_files`/`docs_list` factories in the - stable catalog. Lock the first sentence and first 80 raw characters. -3. Update canonical MCP recovery hints from `code_files` to `list` while - retaining CLI-native legacy rewrites. Replace the two quick-start rows with - one list row. State that a package/repository lists its own source tree, - while hosted docs require an explicit `site:`. Teach callers to search a - package's docs and reuse an emitted site target before browsing; update the - local experimental `resolve_target` guidance to send selected sites to - `list` as well as `search`. + stable catalog without registering callable aliases. Lock the exact + intent-focused first sentence + `List files and documentation paths in a known package, repository, or site.` and + its first 80 raw characters. Add the later exact compatibility sentence + `Replaces code_files and docs_list.` and test that the full description + contains both legacy identifiers. +3. Replace every active MCP reference that sends callers to `code_files` or + `docs_list`: tool descriptions, argument descriptions, validation messages, + structured error/recovery actions, quick-start instructions, local + experimental guidance, and tests. At the `githits code read` and + `githits code grep` boundaries, translate shared `list` recovery wording and + path operands into `githits list [paths...]` rather than leaking MCP + syntax; add one focused recovery test for each command. Replace the two + quick-start rows with one list row. State that a package/repository lists its + own source tree, while hosted docs require an explicit `site:`. Teach callers + to search a package's docs and reuse an emitted site target before browsing; + send selected `resolve_target` sites to `list` as well as `search`. Add a + catalog contract proving no descriptor except `list`'s compatibility + sentence names either retired tool. 4. Update `packages/mcp/src/mcp/instructions.ts` and - `skills/githits-mcp/SKILL.md` together, then update the canonical - `githits-code` skill/reference and implementation docs. Run the plugin - generator/checker. Reconcile Phase 4 of + `skills/githits-mcp/SKILL.md` together, then update implementation docs. Run + the plugin generator/checker. The public `githits-code` skill advertises + released CLI behavior and therefore moves to Phase 3 after the matching + artifacts publish; updating it before release would violate the Agent Skill + lifecycle. Treat MCP guide changes as current guidance for new or refreshed + clients; do not assume already-installed skills update with the release. + Reconcile Phase 4 of `docs/plans/mcp-tool-surface-simplification.md` so it does not independently expand `code_files`. 5. Update catalog, local server, public surface, parity, smoke, and package @@ -573,22 +525,54 @@ guidance. site-list route in its focused guidance tests. Add a separate `minor` fragment for both public artifacts. +Implementation landed as focused service, catalog, migration, smoke, eval, +documentation, and review-fix commits. The stable catalog has 12 tools. The +serialized replacement descriptor (`name`, description, input schema) is 2,892 +UTF-8 bytes, 2,296 bytes (44.3%) below the 5,188-byte retired pair baseline. +The serialized stable catalog is 32,282 UTF-8 bytes. These are payload +measurements, not model-token or latency claims. + +Final rebased verification on 2026-09-29 passed 5,168 tests with zero failures, +typecheck, formatting, lint with only the repository's pre-existing warnings, +root and MCP builds, plugin generation/checks, public-package validation, +157-step production CLI smoke, 65-step production MCP smoke, 38-step built CLI +smoke, and 9-step built MCP registration smoke. The MCP continuation smoke +requires a real cursor and a distinct second entry. + +Five targeted Codex descriptor workloads passed with high confidence and used +default text throughout: continuation, site list-to-read, package/repository +boundaries, recursion versus glob depth, and package-docs discovery followed by +site list/read. Continuation replayed the opaque `after` value; the site case +replayed target-relative `target` and `path`. A matching Claude run could not +start because the local Claude CLI was logged out, so it produced no product +evidence. Codex plus deterministic and live coverage satisfy the practical +"where practical" cross-model requirement without treating that auth failure +as a product result. + ### Verification and acceptance -Run Phase 1's full deterministic commands plus: +Run the full deterministic suite: ```text +bun test +bun run typecheck +bun run format:check +bun run lint +bun run build bun run plugins:generate bun run plugins:check bun run validate:packages +bun run smoke:cli bun run smoke:mcp +bun run smoke:cli:built bun run smoke:mcp:built ``` Measure the replacement descriptor and stable catalog with the same serializer as the 5,188-byte baseline; report bytes without claiming model-token or latency improvement. Run targeted descriptor-only `bun run agent:e2e` workloads named -above with Claude and Codex where practical. Inspect `tool-calls.json`, +above with neutral prompts through Claude and Codex where practical; do not add +legacy tool names to workload prompts. Inspect `tool-calls.json`, `final.json`, `metrics.json`, and `isolation-violations.json`; harness completion alone is not quality evidence. @@ -604,7 +588,10 @@ other backend cases only if the schema or endpoint changes. Phase 2 is accepted when deterministic and targeted live/eval checks pass; the catalog advertises `list` and no longer advertises `code_files`/`docs_list`; -active recovery/guidance names the canonical tool and package-to-site route; +the `list` first sentence and first 80 raw characters remain focused on listing +intent, while its full description contains both exact legacy names; no other +catalog descriptor names them and no callable aliases exist; active +recovery/guidance names the canonical tool and package-to-site route; public exports and request-scoped host construction validate outside root aliases; and docs, skills, generated assets, and release fragment match the final surface. If the increment approaches the implementation-size threshold, @@ -619,7 +606,9 @@ release/deployment authorization. serve the same unified list contract against the hosted backend. **Assumptions:** remote MCP still composes the published public client API per -request; the backend deploy contains the verified SDL or a compatible successor. +request; the backend deploy contains the verified SDL or a compatible +successor; the `list` compatibility wording remains present throughout this +phase. **Unknowns or product decisions:** exact package versions, deployment order/date, and whether endpoint evidence requires a compatible client adjustment. Resolve @@ -630,8 +619,16 @@ is intentionally deferred. authorization for release, remote dependency update, and deployment at each protected step. +After the matching CLI package is published, update the canonical +`skills/githits-code` guidance and generated reference to prefer top-level +`githits list`, while retaining the documented legacy grouped commands through +their grace period. This release-gated skill change is deliberately excluded +from Phase 2. + **Acceptance criteria:** outside-workspace packed CLI and public MCP imports construct `ListService`; published CLI and hosted MCP catalogs expose `list`; +the hosted `list` description still names `code_files` and `docs_list` in its +compatibility sentence while neither legacy tool is callable; live package, repository, and site list calls continue and follow emitted read/browse actions; source indexing and site lifecycle behavior match the documented contract; no hosted fallback reaches deprecated roots; and release @@ -664,3 +661,34 @@ client/live evidence, a dedicated error family, explicit discovery guidance, and two implementation increments. Round 2 was clean after three wording fixes covering Phase 2 public exports, the legacy docs-list pointer, and this history. No finding was rejected and no test was run for the plan-only change. + +The Phase 2 stale-skill compatibility revision also completed internal and +external plan review. External round 1 found two accepted gaps: the proposed +legacy-name agent prompt violated neutral workload rules, and migration did not +cover every active descriptor/argument/error reference. Its suggestion to put +the retired names in the first sentence was initially accepted, then rejected +after product clarification: repository guidance reserves that sentence and +the first 80 raw characters for natural intent selection, while explicit tool +search uses the full description. The exact names therefore live in a later +compatibility sentence. Round 2 found one accepted CLI-boundary gap after +shared recovery wording changes. Round 3 found only the missing `bun run lint` +verification line. Both were corrected; the final external round counts as +clean under the documentation-only finding rule. + +Phase 2 implementation review completed on 2026-09-28. Luna pre-flight found +no code, documentation, or interface mismatch and identified only the unproven +authenticated/eval gates. Internal code review found one accepted smoke gap: +the continuation probe could skip after an exact-file query; `e0889b7` replaced +it with required root pagination and a distinct second entry. External Claude +round 1 found two low-severity maintenance gaps: the release fragment omitted +the required host `listService` migration, and retired unregistered MCP list +factories plus MCP-only renderers remained dead. `e04bfc8` documented the host +contract and removed that dead code while preserving grouped CLI helpers and +their tests. External round 2 re-ran focused tests and typecheck and was clean. + +The 2026-09-29 post-rebase review found the list implementation clean and one +minor shared-guidance mismatch: a blanket JSON sentence conflicted with +`code_diff`, whose text intentionally omits most of a full patch. The shared +guide retained its existing required-field exception, while the `list` +descriptor keeps the stricter programmatic-consumer rule. This keeps ownership +with each tool's formatter and resolved the only finding. diff --git a/eval/agentic/README.md b/eval/agentic/README.md index f23e00c9..ba9b6aa9 100644 --- a/eval/agentic/README.md +++ b/eval/agentic/README.md @@ -943,13 +943,23 @@ use at least one agent for quick iteration. | Dependency graph UX, `pkg_deps` | `package-dependencies.md` | | Release notes UX, `pkg_changelog` | `package-changelog.md`; use `package-changelog-range.md` for range/body-preview behavior and `package-changelog-exact.md` for a pinned selected-release call | | Upgrade evidence UX, `pkg_upgrade_review` | `package-upgrade-safety.md` | -| Documentation browsing, `docs_list`, `read` | `docs-discovery.md`; use `docs-search-followup.md` for search-to-read handoff and `docs-search-noise.md` for noisy docs-result recovery; use `docs-fragment-read.md` for exact indexed section selection | -| File listing / file read UX, `code_files`, `read` | `code-file-navigation.md`; use `code-files-listing.md` for focused listing behavior; use `code-read-window.md` for focused source-window behavior | +| Package, repository, and site inventory plus exact file/page follow-up, `list`, `read` | `list-package-repository.md`, `list-recursion-glob.md`, `list-site-read.md`, `list-continuation.md`, and `list-package-docs-site.md`; use `code-file-navigation.md` and `code-read-window.md` for source navigation, and `docs-discovery.md`, `docs-search-followup.md`, `docs-search-noise.md`, and `docs-fragment-read.md` for documentation search and page selection | | Deterministic source search UX, `code_grep` | `code-grep-investigation.md` | | Multi-tool code navigation strategy and MCP/skill guidance | `express-router.md`; `opencode-compaction.md` is the remote-MCP routing regression derived from the connector transcript | -| Experimental target resolution | `experimental-resolution-follow-up.md`; use `experimental-site-resolution-follow-up.md` for site resolution into docs search | +| Experimental target resolution | `experimental-resolution-follow-up.md`; use `experimental-site-resolution-follow-up.md` for site resolution into documentation search or inventory browsing | | Experimental exact source diff | `experimental-code-diff.md` | +The unified inventory workloads cover separate behavior boundaries: + +- `list-recursion-glob.md` distinguishes immediate directory children from + glob depth, which is independent of recursive directory expansion. +- `list-continuation.md` checks that a returned cursor continues the same + inventory query and reports whether another page remains. +- `list-site-read.md` checks exact site-page locator reuse from inventory into + page follow-up. +- `list-package-docs-site.md` checks package documentation search followed by + browsing the explicitly emitted site target. + For broad MCP quick-start or description edits, start with the cheap Luna-low canary's `discovery` and `intent` scenarios: @@ -1074,12 +1084,13 @@ Notable findings to keep in mind when evaluating future changes: names a source file and line area. Claude Haiku does this directly; Codex mini has been observed doing package/search preflight before the eventual bounded `read`, so review raw calls when tuning general tool-selection guidance. -- `code-files-listing.md` should show direct path enumeration with `code_files`. - Claude Haiku does this directly. Codex mini has been observed oscillating - between `read`, `code_grep`, and `code_files`, and can self-report that - `code_files` is unavailable even when earlier runs used it; treat raw calls as - the source of truth and fix concrete validation/error issues rather than - overfitting instructions to one noisy run. +- Historical `code-files-listing.md` runs predate unified inventory: Claude + Haiku selected `code_files` directly, while Codex mini sometimes oscillated + between `read`, `code_grep`, and `code_files`, including a self-report that it + was unavailable. These are historical observations. For future inventory + behavior, use `list-package-repository.md` or `list-recursion-glob.md` and + expect `list`; use raw calls as the source of truth rather than overfitting + instructions to one noisy run. - `tool-calls.json` is the source of truth for tool usage. The final JSON records only the agent's result status, answer, and confidence; quality assessment is a later review concern. diff --git a/eval/agentic/context-loading/fixture-server.test.ts b/eval/agentic/context-loading/fixture-server.test.ts index 324ac7dd..fb5af776 100644 --- a/eval/agentic/context-loading/fixture-server.test.ts +++ b/eval/agentic/context-loading/fixture-server.test.ts @@ -35,8 +35,22 @@ describe("context fixture MCP contract", () => { expect(result.content).toEqual([ { type: "text", text: buildRoutingGuide() }, ]); - expect(buildRoutingGuide()).toContain(EXTERNAL_CONTENT_POSTURE); - expect(buildRoutingGuide()).not.toContain("ALL_TOOLS"); + const routingGuide = buildRoutingGuide(); + expect(routingGuide).toContain(EXTERNAL_CONTENT_POSTURE); + expect(routingGuide).not.toContain("ALL_TOOLS"); + expect(routingGuide).toContain( + "| List files or documentation pages in a package, repository, or explicit site | list |", + ); + expect(routingGuide).toContain("returned by search or list | read |"); + expect(routingGuide).toContain( + "reuse an emitted explicit site target before listing pages", + ); + for (const retiredName of [ + ["code", "files"].join("_"), + ["docs", "list"].join("_"), + ]) { + expect(routingGuide).not.toContain(retiredName); + } expect( ROUTING_QUICK_START_DESCRIPTION.split(".")[0]?.length, ).toBeLessThan(79); diff --git a/eval/agentic/context-loading/routing-guide.ts b/eval/agentic/context-loading/routing-guide.ts index 8a9fdf0d..2ae33cbc 100644 --- a/eval/agentic/context-loading/routing-guide.ts +++ b/eval/agentic/context-loading/routing-guide.ts @@ -19,10 +19,8 @@ the routing decision; the selected tool supplies its argument details. | --- | --- | | Find a known literal or regex in a public repository/package | code_grep | | Find relevant source, symbols, tests, or documentation for a topic | search | -| List paths or browse a source directory | code_files | -| Read a known exact source file or matched lines | read | -| Browse package documentation pages | docs_list | -| Read a documentation page returned by search or docs_list | read | +| List files or documentation pages in a package, repository, or explicit site | list | +| Read an exact source file or documentation page returned by search or list | read | | Assess a package's license, adoption, maintenance, or overall health | pkg_info | | Inspect vulnerabilities in a package or version | pkg_vulns | | Inspect direct dependencies or transitive footprint | pkg_deps | @@ -31,6 +29,11 @@ the routing decision; the selected tool supplies its argument details. | Find canonical implementation examples across projects | get_example | | Check progress of an earlier search reference | search_status | +Package and repository inventories include their own source and documentation +files. Hosted documentation uses an explicit site target; listing a package does +not discover its hosted site. For package-hosted docs, search the package +documentation and reuse an emitted explicit site target before listing pages. + If get_example cannot match a language, retry with a suggested language from the error, or omit language. For comparative questions, combine the relevant package/source route with examples when needed. diff --git a/eval/agentic/suites.json b/eval/agentic/suites.json index 748f1583..9e41d0f8 100644 --- a/eval/agentic/suites.json +++ b/eval/agentic/suites.json @@ -97,6 +97,36 @@ "safety": "stable", "suites": ["smoke", "stable-full"] }, + { + "id": "list-continuation", + "path": "eval/agentic/workloads/list-continuation.md", + "safety": "stable", + "suites": ["stable-full"] + }, + { + "id": "list-package-docs-site", + "path": "eval/agentic/workloads/list-package-docs-site.md", + "safety": "stable", + "suites": ["stable-full"] + }, + { + "id": "list-package-repository", + "path": "eval/agentic/workloads/list-package-repository.md", + "safety": "stable", + "suites": ["stable-full"] + }, + { + "id": "list-recursion-glob", + "path": "eval/agentic/workloads/list-recursion-glob.md", + "safety": "stable", + "suites": ["stable-full"] + }, + { + "id": "list-site-read", + "path": "eval/agentic/workloads/list-site-read.md", + "safety": "stable", + "suites": ["stable-full"] + }, { "id": "opencode-compaction", "path": "eval/agentic/workloads/opencode-compaction.md", diff --git a/eval/agentic/workloads/list-continuation.md b/eval/agentic/workloads/list-continuation.md new file mode 100644 index 00000000..f49cf6c5 --- /dev/null +++ b/eval/agentic/workloads/list-continuation.md @@ -0,0 +1,6 @@ +# Workload: Inventory Continuation + +For `github:expressjs/express`, retrieve inventory pages of size 3. Gather the +first default-text page, then make exactly one continuation using the cursor +shown in that text. Report the paths in both batches and whether more results +remain. Do not request JSON. diff --git a/eval/agentic/workloads/list-package-docs-site.md b/eval/agentic/workloads/list-package-docs-site.md new file mode 100644 index 00000000..c276c79f --- /dev/null +++ b/eval/agentic/workloads/list-package-docs-site.md @@ -0,0 +1,6 @@ +# Workload: Package Documentation Site Follow-Up + +Starting from `npm:express`, find hosted documentation about community +resources. Reuse the explicitly emitted documentation-site target to browse +its inventory, then review one returned page and cite its exact returned target +and path. Do not reconstruct a URL from a display path. diff --git a/eval/agentic/workloads/list-package-repository.md b/eval/agentic/workloads/list-package-repository.md new file mode 100644 index 00000000..3899f843 --- /dev/null +++ b/eval/agentic/workloads/list-package-repository.md @@ -0,0 +1,5 @@ +# Workload: Package And Repository Inventory Boundaries + +Compare the root path inventories for `npm:express@5.2.1` and +`github:expressjs/express`. Report a concise path set for each target and +explain the inventory-boundary difference. Do not inspect file contents. diff --git a/eval/agentic/workloads/list-recursion-glob.md b/eval/agentic/workloads/list-recursion-glob.md new file mode 100644 index 00000000..aefb82d7 --- /dev/null +++ b/eval/agentic/workloads/list-recursion-glob.md @@ -0,0 +1,5 @@ +# Workload: Directory Expansion And Glob Depth + +For `npm:express@5.2.1`, separately enumerate the immediate children selected +by `lib/` and the JavaScript matches selected by `lib/**/*.js`. Report which +paths came from each query. Do not inspect file contents. diff --git a/eval/agentic/workloads/list-site-read.md b/eval/agentic/workloads/list-site-read.md new file mode 100644 index 00000000..0d044248 --- /dev/null +++ b/eval/agentic/workloads/list-site-read.md @@ -0,0 +1,6 @@ +# Workload: Site Inventory And Page Follow-Up + +Within the exact site target `site:expressjs.com`, browse the inventory under +`en/resources/`. Select the Community page from the returned entries, review +enough of that page to state its purpose, and cite the exact target and path +returned for it. Reuse the emitted locator without reconstructing a URL. diff --git a/packages/mcp/src/client.ts b/packages/mcp/src/client.ts index c0eb6ce4..e84550e0 100644 --- a/packages/mcp/src/client.ts +++ b/packages/mcp/src/client.ts @@ -22,6 +22,25 @@ export type { ContentModification, ContentSafety, GitHitsService, + ListAction, + ListAvailableVersion, + ListBrowseAction, + ListEntry, + ListEntryKind, + ListFileIntent, + ListIndexingEstimate, + ListInventoryKind, + ListParams, + ListReadAction, + ListResolution, + ListResult, + ListService, + ListSiteJob, + ListSitePreparation, + ListSiteWait, + ListSiteWaitOutcome, + ListTargetIdentity, + ListTargetResolution, PackageIntelligenceService, RawCodeDiff, RawCodeDiffContentCoverage, @@ -57,6 +76,12 @@ export { getCodeNavigationUrl, getEnvApiToken, getMcpUrl, + ListAccessError, + ListBackendError, + ListGraphQLError, + ListNetworkError, + ListServiceImpl, + MalformedListResponseError, PackageIntelligenceServiceImpl, PKGSEER_REGISTRY_LIST, ReadServiceImpl, diff --git a/packages/mcp/src/internal.ts b/packages/mcp/src/internal.ts index ab62f493..c680b255 100644 --- a/packages/mcp/src/internal.ts +++ b/packages/mcp/src/internal.ts @@ -34,10 +34,8 @@ export * from "./shared/grep-text.js"; export * from "./shared/list-error-map.js"; export * from "./shared/list-files-request.js"; export * from "./shared/list-files-response.js"; -export * from "./shared/list-files-text.js"; export * from "./shared/list-package-docs-request.js"; export * from "./shared/list-package-docs-response.js"; -export * from "./shared/list-package-docs-text.js"; export * from "./shared/list-request.js"; export * from "./shared/list-response.js"; export * from "./shared/list-text.js"; diff --git a/packages/mcp/src/mcp/instructions.test.ts b/packages/mcp/src/mcp/instructions.test.ts index 35e8d05a..e32d323c 100644 --- a/packages/mcp/src/mcp/instructions.test.ts +++ b/packages/mcp/src/mcp/instructions.test.ts @@ -29,19 +29,32 @@ describe("buildLocalMcpQuickStart", () => { expect(quickStart).toContain( "patterns are `registry:name@version` and `github:owner/repo@ref`", ); + expect(quickStart).toContain( + "Browse files or documentation pages in a known package, repository, or site | `list`", + ); + expect(quickStart).toContain( + "`list` is for a known target when you need its structure or an exact path", + ); + expect(quickStart).toContain("use `search` for content by topic"); + expect(quickStart).toContain("both include source and\ndocumentation"); + expect(quickStart).toContain( + "explicit `site:` inventory that\n`list` does not discover", + ); + expect(quickStart).not.toContain("`code_files`"); + expect(quickStart).not.toContain("`docs_list`"); expect(quickStart).toContain( "suffix for the latest package version or repository default branch", ); expect(quickStart).not.toContain("[@version]"); expect(quickStart).not.toContain("[@ref]"); expect(quickStart).toContain( - "Use snippets when sufficient; otherwise read the target in a `[docs page]`", + "Use snippets when sufficient; otherwise read the\ntarget in that header", ); expect(quickStart).toContain( "Hosted/crawled HTTP(S) docs locators address mutable current content", ); expect(quickStart).toContain( - "search header. For an exact section or bounds, request search JSON", + "target in that header. For an exact section or bounds, request search JSON", ); expect(quickStart).toContain( "its `followUp` unchanged, including supplied `selector` and bounds", @@ -113,7 +126,13 @@ describe("buildLocalMcpQuickStart", () => { expect(instructions).toContain("documentation-site names"); expect(instructions).toContain("`site:`"); expect(instructions).toContain('`source:"docs"`'); - expect(instructions).toContain("request JSON only for missing fields"); + expect(instructions).toContain("pass it to `list` to browse pages"); + expect(instructions).toContain( + 'or to `search` with `source:"docs"` for topic search', + ); + expect(instructions).toContain( + "keep text unless code consumes the raw response", + ); expect(instructions).toContain( "replay the complete emitted read action unchanged, otherwise use its returned target/range", ); @@ -128,7 +147,9 @@ describe("buildLocalMcpQuickStart", () => { expect(instructions).toContain("`pkg_upgrade_review`"); expect(instructions).toContain("public repository refs repository-wide"); expect(instructions).toContain("name-status"); - expect(instructions).toContain("full returned patch"); + expect(instructions).toContain( + "use `json` only for required fields absent from text or the full returned patch", + ); expect(instructions).toContain("diffs do not prove compatibility"); expect(instructions).toContain("credentials"); expect(instructions).toContain("private or proprietary content"); diff --git a/packages/mcp/src/mcp/instructions.ts b/packages/mcp/src/mcp/instructions.ts index 81a49cda..a67d1d7f 100644 --- a/packages/mcp/src/mcp/instructions.ts +++ b/packages/mcp/src/mcp/instructions.ts @@ -10,9 +10,8 @@ This guide owns shared policy; selected tools own call syntax and exceptions. | --- | --- | | Find a known literal or regex in a public repository/package | \`code_grep\` | | Find relevant source, symbols, tests, or documentation for a topic | \`search\` | -| List paths or browse a source directory | \`code_files\` | +| Browse files or documentation pages in a known package, repository, or site | \`list\` | | Read a source file, code symbol, or documentation section | \`read\` | -| Browse package documentation pages | \`docs_list\` | | Assess a package's license, adoption, maintenance, or overall health | \`pkg_info\` | | Inspect vulnerabilities in a package or version | \`pkg_vulns\` | | Inspect direct dependencies or transitive footprint | \`pkg_deps\` | @@ -33,12 +32,16 @@ Use public repository targets for full repositories or sibling packages: A ref may be a branch, tag, or commit and contain later \`@\`; \`#\` is for semantic fragments, not revisions. -For a package or site docs topic, use \`search\` with \`source:"docs"\`. -\`docs_list\` browses package pages, not standalone \`site:\` targets. -Use snippets when sufficient; otherwise read the target in a \`[docs page]\` -search header. For an exact section or bounds, request search JSON and replay -its \`followUp\` unchanged, including supplied \`selector\` and bounds. -Replay a \`docs_list\` read action unchanged when browsing package pages. +\`list\` is for a known target when you need its structure or an exact path; +use \`search\` for content by topic. A package target covers its own source tree, +while a repository target covers the whole snapshot; both include source and +documentation. Hosted docs use a separate explicit \`site:\` inventory that +\`list\` does not discover. For hosted package docs, search the package with +\`source:"docs"\`, then pass the explicit \`site:\` target from a \`[docs page]\` +search header to \`list\`. Use snippets when sufficient; otherwise read the +target in that header. For an exact section or bounds, request search JSON and +replay its \`followUp\` unchanged, including supplied \`selector\` and bounds. +Replay a \`list\` read action unchanged when browsing paths. Hosted/crawled HTTP(S) docs locators address mutable current content. A direct HTTP(S) docs fragment read without explicit bounds returns its heading and full subtree through the next equal-or-higher heading. @@ -127,7 +130,7 @@ const LOCAL_RESEARCH_GUIDANCE_END = ' Reuse a returned `thread_id` for follow-ups. Change project, version, or topic in the follow-up question. Sources default to directly callable MCP tools; use `source_format:"url"` for original upstream URLs. Do not invent or rewrite sources.'; const LOCAL_RESOLVE_TARGET_GUIDANCE = - '- `resolve_target` — resolve fuzzy, misspelled, or noncanonical package, repository, or documentation-site names; skip canonical `registry:name`, `github:owner/repo`, `codeberg:owner/repo`, `gitlab:group/subgroup/project`, and `site:`. Reuse only an unambiguous EXACT/HIGH best target with CLEAR or NOT_APPLICABLE malicious-content status; CLEAR is not a vulnerability-free claim. Other or missing statuses are non-actionable. For MEDIUM/LOW or ambiguity, narrow or explicitly choose an actionable candidate; never auto-select. A selected `site:` is docs-only: pass it to `search` with `source:"docs"`; request JSON only for missing fields; replay the complete emitted read action unchanged, otherwise use its returned target/range.'; + '- `resolve_target` — resolve fuzzy, misspelled, or noncanonical package, repository, or documentation-site names; skip canonical `registry:name`, `github:owner/repo`, `codeberg:owner/repo`, `gitlab:group/subgroup/project`, and `site:`. Reuse only an unambiguous EXACT/HIGH best target with CLEAR or NOT_APPLICABLE malicious-content status; CLEAR is not a vulnerability-free claim. Other or missing statuses are non-actionable. For MEDIUM/LOW or ambiguity, narrow or explicitly choose an actionable candidate; never auto-select. A selected `site:` is docs-only: pass it to `list` to browse pages or to `search` with `source:"docs"` for topic search; keep text unless code consumes the raw response; replay the complete emitted read action unchanged, otherwise use its returned target/range.'; const LOCAL_CODE_DIFF_GUIDANCE = "- `code_diff` — compare exact package versions or public repository refs repository-wide after canonicalization. Prefer `pkg_changelog` or `pkg_upgrade_review` for upgrade summaries. Start with default `name-status`; use `stat` for magnitude or a scoped `patch` for content. Keep `text`; use `json` only for required fields absent from text or the full returned patch. Treat truncation, coverage, and safety warnings as evidence limits; diffs do not prove compatibility."; diff --git a/packages/mcp/src/mcp/local-server.test.ts b/packages/mcp/src/mcp/local-server.test.ts index 8220129b..174f2fbc 100644 --- a/packages/mcp/src/mcp/local-server.test.ts +++ b/packages/mcp/src/mcp/local-server.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, mock } from "bun:test"; import type { AgenticAskService, + ListService, ResolveTargetService, } from "@githits/core-internal"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; @@ -29,10 +30,9 @@ const EXPECTED_STABLE_NAMES = [ "get_example", "search", "search_status", - "code_files", + "list", "read", "code_grep", - "docs_list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -70,6 +70,7 @@ function createServices( githitsService: createMockGitHitsService(), codeNavigationService: createMockCodeNavigationService(), packageIntelligenceService: createMockPackageIntelligenceService(), + listService: createMockListService(), readService: createMockReadService(), agenticAskService: { ask: mock(() => @@ -81,6 +82,12 @@ function createServices( }; } +function createMockListService(): ListService { + return { + list: mock(() => Promise.reject(new Error("unused"))), + }; +} + function registeredToolNames(server: ReturnType) { return Object.keys( ( @@ -144,7 +151,10 @@ describe("createLocalMcpServer", () => { expect(registeredToolNames(server)).toEqual([...EXPECTED_STABLE_NAMES]); expect(registeredToolNames(server)).not.toContain("ask"); expect(registeredToolNames(server)).not.toContain("research"); - expect(registeredToolNames(server)).toHaveLength(13); + expect(registeredToolNames(server)).toHaveLength(12); + expect(registeredToolNames(server)).toContain("list"); + expect(registeredToolNames(server)).not.toContain("code_files"); + expect(registeredToolNames(server)).not.toContain("docs_list"); expect(registeredToolNames(server)).toContain("read"); expect(registeredToolNames(server)).not.toContain("code_read"); expect(registeredToolNames(server)).not.toContain("docs_read"); @@ -178,7 +188,10 @@ describe("createLocalMcpServer", () => { ]); expect(registeredToolNames(server)).toContain("research"); expect(registeredToolNames(server)).not.toContain("ask"); - expect(registeredToolNames(server)).toHaveLength(16); + expect(registeredToolNames(server)).toHaveLength(15); + expect(registeredToolNames(server)).toContain("list"); + expect(registeredToolNames(server)).not.toContain("code_files"); + expect(registeredToolNames(server)).not.toContain("docs_list"); expect(registeredToolNames(server)).not.toContain("code_read"); expect(registeredToolNames(server)).not.toContain("docs_read"); expect(serverInstructions(server)).toBeUndefined(); @@ -246,7 +259,7 @@ describe("createLocalMcpServer", () => { }); const tools = registeredTools(server); - for (const name of ["code_files", "code_grep", "code_diff"] as const) { + for (const name of ["list", "code_grep", "code_diff"] as const) { const schema = z.toJSONSchema(tools[name]?.inputSchema as z.ZodObject); const targetSchema = schema.properties?.target as | { properties?: unknown; type?: string } diff --git a/packages/mcp/src/mcp/server.test.ts b/packages/mcp/src/mcp/server.test.ts index d59081f3..4eb3d194 100644 --- a/packages/mcp/src/mcp/server.test.ts +++ b/packages/mcp/src/mcp/server.test.ts @@ -23,10 +23,9 @@ const FORMAT_SELECTABLE_TOOLS = new Set([ "get_example", "search", "search_status", - "code_files", + "list", "read", "code_grep", - "docs_list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -39,10 +38,9 @@ const STABLE_MCP_TOOL_NAMES = [ "get_example", "search", "search_status", - "code_files", + "list", "read", "code_grep", - "docs_list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -95,9 +93,24 @@ const DESCRIPTION_ROUTING: Record< "`search_status`", ], }, - code_files: { - prefix: /^List indexed files and paths in a public repo or package\./, - body: ["`read`", "`code_grep`"], + list: { + prefix: + /^List files and documentation paths in a known package, repository, or site\./, + exactPrefix: + "List files and documentation paths in a known package, repository, or site. Use ", + body: [ + "Replaces code_files and docs_list.", + "find an exact path before `read`", + "use `search` for topics", + "one package-owned tree", + "whole snapshot", + "Both include source and documentation", + "explicit `site:` target", + "target-relative literals or globs", + "glob depth is independent", + "read and continuation guidance", + "use JSON only when code consumes", + ], }, read: { prefix: @@ -105,7 +118,7 @@ const DESCRIPTION_ROUTING: Record< exactPrefix: "Read an indexed source file, code symbol, or documentation section. Pass target ", body: [ - "use code_files", + "use list", "search/code_grep", "target and path for a file or site page; use compact target#symbol or selector for a code symbol", "resolved result determines code or docs", @@ -125,14 +138,6 @@ const DESCRIPTION_ROUTING: Record< /^Find text, regex, or identifier matches in a public repo or package\./, body: ["deterministic and paginated", "`read.path`", "`match.line`"], }, - docs_list: { - prefix: /^List package documentation targets for follow-up reads\./, - body: [ - "`read.target`", - "`docsReadTarget`", - "not standalone `site:` targets", - ], - }, pkg_info: { prefix: /^Assess latest package health and adoption/, exactPrefix: @@ -201,8 +206,11 @@ describe("MCP tool annotations", () => { const descriptors = getMcpToolDescriptors(); expect(descriptors.map(({ name }) => name)).not.toContain("feedback"); - expect(descriptors).toHaveLength(13); + expect(descriptors).toHaveLength(12); expect(descriptors.map(({ name }) => name)).toContain("read"); + expect(descriptors.map(({ name }) => name)).toContain("list"); + expect(descriptors.map(({ name }) => name)).not.toContain("code_files"); + expect(descriptors.map(({ name }) => name)).not.toContain("docs_list"); expect(descriptors.map(({ name }) => name)).not.toContain("code_read"); expect(descriptors.map(({ name }) => name)).not.toContain("docs_read"); @@ -223,6 +231,8 @@ describe("MCP tool description catalog", () => { expect(descriptors.map(({ name }) => name)).toEqual([ ...STABLE_MCP_TOOL_NAMES, ]); + expect(descriptors.map(({ name }) => name)).not.toContain("code_files"); + expect(descriptors.map(({ name }) => name)).not.toContain("docs_list"); expect(descriptors.map(({ name }) => name)).not.toContain("code_read"); expect(descriptors.map(({ name }) => name)).not.toContain("docs_read"); const catalogPrefixes = descriptors.map(({ description }) => @@ -285,6 +295,24 @@ describe("MCP tool description catalog", () => { ).not.toContain(phrase); } + if (descriptor.name === "list") { + const firstSentence = renderDeferredCatalogSummary( + descriptor.description, + ); + expect(firstSentence).toBe( + "List files and documentation paths in a known package, repository, or site.", + ); + expect(firstSentence.length).toBeLessThanOrEqual(79); + expect(descriptor.description.slice(0, 80)).not.toContain("code_files"); + expect(descriptor.description.slice(0, 80)).not.toContain("docs_list"); + expect(descriptor.description).toContain( + "Replaces code_files and docs_list.", + ); + } else { + expect(descriptor.description).not.toContain("code_files"); + expect(descriptor.description).not.toContain("docs_list"); + } + if (descriptor.name === "quick_start") { expect(descriptor.description).not.toContain(QUICK_START_PREREQUISITE); } else { @@ -374,7 +402,21 @@ describe("MCP code_grep schema", () => { describe("MCP compact target schemas", () => { it.each([ - ["docs_list", ["after", "format", "limit", "target"]], + [ + "list", + [ + "after", + "file_types", + "format", + "intents", + "languages", + "limit", + "paths", + "recursive", + "target", + "wait_timeout_ms", + ], + ], ["pkg_info", ["format", "target", "verbose"]], [ "pkg_vulns", @@ -437,7 +479,7 @@ describe("MCP compact target schemas", () => { it("uses strings for code and discovery targets without nested coordinates", () => { const descriptors = getMcpToolDescriptors(); - for (const name of ["code_files", "code_grep"] as const) { + for (const name of ["list", "code_grep"] as const) { const descriptor = descriptors.find( (candidate) => candidate.name === name, ); diff --git a/packages/mcp/src/mcp/server.ts b/packages/mcp/src/mcp/server.ts index 575f1c24..43fd2442 100644 --- a/packages/mcp/src/mcp/server.ts +++ b/packages/mcp/src/mcp/server.ts @@ -3,8 +3,7 @@ import { type CompleteToolAnnotations, createGetExampleTool, createGrepRepoTool, - createListFilesTool, - createListPackageDocsTool, + createListTool, createPackageChangelogTool, createPackageDependenciesTool, createPackageSummaryTool, @@ -89,15 +88,10 @@ const STABLE_MCP_OPERATION_FACTORIES: readonly McpToolFactory[] = [ (services) => eraseMcpTool(createSearchTool(services.codeNavigationService)), (services) => eraseMcpTool(createSearchStatusTool(services.codeNavigationService)), - (services) => - eraseMcpTool(createListFilesTool(services.codeNavigationService)), + (services) => eraseMcpTool(createListTool(services.listService)), (services) => eraseMcpTool(createReadTool(services)), (services) => eraseMcpTool(createGrepRepoTool(services.codeNavigationService)), - (services) => - eraseMcpTool( - createListPackageDocsTool(services.packageIntelligenceService), - ), (services) => eraseMcpTool(createPackageSummaryTool(services.packageIntelligenceService)), (services) => @@ -339,6 +333,9 @@ export function createDescriptorServices(): McpToolServices { listPackageDocs: fail, readPackageDoc: fail, }, + listService: { + list: fail, + }, readService: { read: fail, }, diff --git a/packages/mcp/src/shared/file-path-recovery.ts b/packages/mcp/src/shared/file-path-recovery.ts index d19b5aa9..9d9de363 100644 --- a/packages/mcp/src/shared/file-path-recovery.ts +++ b/packages/mcp/src/shared/file-path-recovery.ts @@ -16,8 +16,8 @@ export function withGrepFileRecovery(mapped: MappedError): MappedError { const prefix = buildContainingPathPrefix(mapped.details.filePath); const listing = prefix === "" - ? "Use `code_files` without `path_prefix`" - : `Use \`code_files\` with \`path_prefix: ${JSON.stringify(prefix)}\``; + ? "Use `list` without `paths`" + : `Use \`list\` with \`paths: ${JSON.stringify([prefix])}\``; return { ...mapped, details: { @@ -52,8 +52,8 @@ export function withExactPathAuthorityRecovery( const prefix = buildContainingPathPrefix(mapped.details.filePath); const listing = prefix === "" - ? "Use `code_files` without `path_prefix`" - : `Use \`code_files\` with \`path_prefix: ${JSON.stringify(prefix)}\``; + ? "Use `list` without `paths`" + : `Use \`list\` with \`paths: ${JSON.stringify([prefix])}\``; const reason = mapped.code === "FILE_PATH_EXCLUDED" ? "This path is excluded from the indexed source." diff --git a/packages/mcp/src/shared/grep-repo-request.test.ts b/packages/mcp/src/shared/grep-repo-request.test.ts index 2aab3360..95eeab36 100644 --- a/packages/mcp/src/shared/grep-repo-request.test.ts +++ b/packages/mcp/src/shared/grep-repo-request.test.ts @@ -177,13 +177,13 @@ describe("buildGrepRepoParams", () => { expect(params.pattern).toBe(" middleware "); }); - it("rejects omitted patterns with a code_files recovery hint", () => { + it("routes omitted patterns to list when the intent is file enumeration", () => { expect(() => buildGrepRepoParams({ target, pathPrefix: "lib/", }), - ).toThrow(/pattern.*code_files/); + ).toThrow(/pattern.*use `list` instead/); }); }); diff --git a/packages/mcp/src/shared/grep-repo-request.ts b/packages/mcp/src/shared/grep-repo-request.ts index 5768513d..abb8249e 100644 --- a/packages/mcp/src/shared/grep-repo-request.ts +++ b/packages/mcp/src/shared/grep-repo-request.ts @@ -85,15 +85,15 @@ export interface GrepRepoRequestBuildResult { }; } -// CLI rewrites pattern, code_files, globs, extensions, and symbol_fields from -// these errors; keep them stable with src/commands/code/grep.ts or update its tests. +// CLI translates pattern, list, globs, extensions, and symbol_fields from +// these errors; keep them stable with src/commands/code/grep.ts and its tests. export function buildGrepRepoParams( input: GrepRepoRequestInput, ): GrepRepoRequestBuildResult { const pattern = input.pattern ?? ""; if (pattern.length === 0 || pattern.trim().length === 0) { throw new InvalidPackageSpecError( - "`pattern` is required — pass the text to search for. If you are trying to list files or count files in scope, use `code_files` instead.", + "`pattern` is required — pass the text to search for. If you are trying to list files or count files in scope, use `list` instead.", ); } if (Buffer.byteLength(pattern, "utf8") > PATTERN_MAX) { diff --git a/packages/mcp/src/shared/list-files-response.ts b/packages/mcp/src/shared/list-files-response.ts index d367c187..24d34d83 100644 --- a/packages/mcp/src/shared/list-files-response.ts +++ b/packages/mcp/src/shared/list-files-response.ts @@ -1,7 +1,6 @@ /** - * Response envelope for the `list_files` tool. Shared across CLI - * `--json` output and MCP `content[0].text`. Terminal formatter is - * CLI-only; both surfaces read the same envelope shape. + * Response envelope for the legacy grouped CLI `githits code files` + * command. Its `--json` and terminal output use the same envelope. * * Design commitments (match the shipped pkg-intel envelope playbook): * @@ -10,7 +9,7 @@ * appears when empty results carry a backend diagnostic. * - **No indexing metadata in the success envelope.** The service * layer promotes `codeIndexState: INDEXING` to a typed error - * before the envelope builder runs, so agents never branch on a + * before the envelope builder runs, so consumers never branch on a * data-path indexing flag. * - **`filter.*` echoes only caller-supplied inputs.** The default * limit (200) is not echoed; explicit selectors / filters are. diff --git a/packages/mcp/src/shared/list-files-text.test.ts b/packages/mcp/src/shared/list-files-text.test.ts deleted file mode 100644 index 279a7cf6..00000000 --- a/packages/mcp/src/shared/list-files-text.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { parseCodeNavigationTargetSpec } from "./code-navigation-target.js"; -import type { LeanListFilesEnvelope } from "./list-files-response.js"; -import { renderListFilesText } from "./list-files-text.js"; - -function envelope( - overrides: Partial = {}, -): LeanListFilesEnvelope { - return { - registry: "npm", - name: "express", - indexedVersion: "v5.2.1", - resolution: { requestedVersion: "5.2.1" }, - total: 2, - hasMore: false, - files: [ - { path: "src/index.js", language: "javascript", fileType: "SOURCE" }, - { path: "src/lib/app.js", language: "javascript", fileType: "SOURCE" }, - ], - ...overrides, - }; -} - -describe("renderListFilesText", () => { - it("renders a paths-only listing with version-tagged identity", () => { - const text = renderListFilesText(envelope()); - expect(text).toContain("code_files | 2 paths | npm:express@5.2.1"); - expect(text).toContain("src/index.js"); - expect(text).toContain("src/lib/app.js"); - // No trailing metadata in default mode. - expect(text).not.toContain("javascript"); - expect(text).not.toContain("SOURCE"); - }); - - it("emits the served repository commit as a reusable read target", () => { - const commit = "dbac741a49a5a64336b70c06e85c2e2706e36336"; - const text = renderListFilesText( - envelope({ - indexedVersion: commit, - resolution: { resolvedRef: commit, commitSha: commit }, - targetResolution: { - served: { - repoUrl: "https://github.com/expressjs/express", - gitRef: "v5.2.1", - commitSha: commit, - version: "5.2.1", - }, - freshness: "current", - availableVersions: [], - availableRefs: [], - }, - }), - ); - expect(text).toContain( - `code_files | 2 paths | github:expressjs/express@${commit}`, - ); - expect(text).not.toContain(`npm:express@${commit}`); - expect(text).not.toContain(`#v5.2.1@`); - expect( - parseCodeNavigationTargetSpec(text.split(" | ")[2]!.split("\n")[0]!), - ).toEqual({ - repoUrl: "https://github.com/expressjs/express", - gitRef: commit, - }); - }); - - it("does not turn an untyped indexed Git ref into a package version", () => { - const text = renderListFilesText( - envelope({ - indexedVersion: "dbac741a49a5a64336b70c06e85c2e2706e36336", - resolution: { resolvedRef: "main" }, - }), - ); - expect(text.split("\n")[0]).toBe("code_files | 2 paths | npm:express"); - }); - - it("keeps a served package version ahead of the requested version", () => { - const text = renderListFilesText( - envelope({ - resolution: { requestedVersion: "5.2.1" }, - targetResolution: { - served: { registry: "npm", packageName: "express", version: "5.1.0" }, - availableVersions: [], - availableRefs: [], - }, - }), - ); - expect(text.split("\n")[0]).toBe( - "code_files | 2 paths | npm:express@5.1.0", - ); - }); - - it("uses repo addressing when no registry is provided", () => { - const text = renderListFilesText( - envelope({ - registry: undefined, - name: undefined, - indexedVersion: undefined, - repoUrl: "https://github.com/cline/cline", - gitRef: "v3.4.2", - }), - ); - expect(text).toContain("code_files | 2 paths | github:cline/cline@v3.4.2"); - }); - - it("emits a truncation hint with N+ count when hasMore", () => { - const text = renderListFilesText(envelope({ hasMore: true, total: 2 })); - expect(text).toContain("code_files | 2+ paths"); - expect(text).toContain("More files available."); - }); - - it("echoes explicit filter inputs in the header", () => { - const text = renderListFilesText( - envelope({ - filter: { - path: "README.md", - pathPrefix: "src/lib", - globs: ["test/**/*.js"], - extensions: ["js"], - fileTypes: ["source"], - languages: ["JavaScript"], - fileIntent: "production", - excludeDocFiles: true, - includeHidden: true, - limit: 50, - }, - }), - ); - expect(text).toContain( - 'path="README.md" path_prefix="src/lib" globs=test/**/*.js exts=js file_types=source languages=JavaScript file_intent=production exclude_doc_files=true include_hidden=true limit=50', - ); - }); - - it("renders the empty-result hint when no files match", () => { - const text = renderListFilesText( - envelope({ - files: [], - total: 0, - hint: "No files match this path prefix.", - }), - ); - expect(text).toContain("code_files | 0 paths"); - expect(text).toContain("No files match this path prefix."); - }); - - it("uses ASCII separators throughout", () => { - const text = renderListFilesText( - envelope({ - hasMore: true, - filter: { - pathPrefix: "src/", - extensions: ["ts"], - limit: 50, - }, - }), - ); - expect(text).not.toMatch(/[·…—–]/); - }); -}); diff --git a/packages/mcp/src/shared/list-files-text.ts b/packages/mcp/src/shared/list-files-text.ts deleted file mode 100644 index 3e0d27e7..00000000 --- a/packages/mcp/src/shared/list-files-text.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Line-oriented text renderer for `code_files` MCP responses. - * - * Paths-only listing (one file per line) — the most compact useful - * shape for an agent that will follow up with `read`. This is - * the tool's default response format; programmatic / parity callers - * opt into the structured JSON envelope via `format: "json"`. - * - * ASCII-only output. Format is a public contract — locked with - * snapshot-style tests in `list-files-text.test.ts`. - */ - -import type { LeanListFilesEnvelope } from "./list-files-response.js"; -import { formatRepositoryTarget } from "./repository-target.js"; -import { buildTargetResolutionNotes } from "./target-resolution.js"; - -const SEP = " | "; - -export function renderListFilesText(envelope: LeanListFilesEnvelope): string { - const lines: string[] = []; - lines.push(buildHeader(envelope)); - lines.push(""); - - if (envelope.files.length === 0) { - lines.push(envelope.hint ?? "No files match the requested filter."); - appendTargetResolutionNotes(lines, envelope); - return lines.join("\n"); - } - - for (const entry of envelope.files) { - lines.push(entry.path); - } - - if (envelope.hasMore) { - lines.push(""); - lines.push("More files available. Pass limit=N or refine the filter."); - } - - if (envelope.hint) { - lines.push(""); - lines.push(envelope.hint); - } - - appendTargetResolutionNotes(lines, envelope); - - return lines.join("\n"); -} - -function appendTargetResolutionNotes( - lines: string[], - envelope: LeanListFilesEnvelope, -): void { - const notes = buildTargetResolutionNotes(envelope.targetResolution); - if (notes.length === 0) return; - lines.push(""); - for (const note of notes) lines.push(note); -} - -function buildHeader(envelope: LeanListFilesEnvelope): string { - const identity = buildIdentity(envelope); - const countValue = envelope.hasMore - ? `${envelope.files.length}+` - : String(envelope.total); - const parts = [ - `code_files${SEP}${countValue} path${countValue === "1" ? "" : "s"}`, - ]; - if (identity) parts.push(identity); - const filter = buildFilterEcho(envelope); - if (filter) parts.push(filter); - return parts.join(SEP); -} - -function buildIdentity(envelope: LeanListFilesEnvelope): string { - // indexedVersion/resolvedRef may be Git refs, not package versions. Follow - // the served repository snapshot so the displayed target can be read verbatim. - const served = envelope.targetResolution?.served; - if (served?.repoUrl) { - return formatRepositoryTarget( - served.repoUrl, - served.commitSha ?? served.gitRef, - ); - } - if (envelope.registry && envelope.name) { - const version = served?.version ?? envelope.resolution?.requestedVersion; - return version - ? `${envelope.registry}:${envelope.name}@${version}` - : `${envelope.registry}:${envelope.name}`; - } - if (envelope.repoUrl) { - return formatRepositoryTarget(envelope.repoUrl, envelope.gitRef); - } - return ""; -} - -function buildFilterEcho(envelope: LeanListFilesEnvelope): string { - const parts: string[] = []; - if (envelope.filter?.path) { - parts.push(`path=${quote(envelope.filter.path)}`); - } - if (envelope.filter?.pathPrefix) { - parts.push(`path_prefix=${quote(envelope.filter.pathPrefix)}`); - } - if (envelope.filter?.globs?.length) { - parts.push(`globs=${envelope.filter.globs.join(",")}`); - } - if (envelope.filter?.extensions?.length) { - parts.push(`exts=${envelope.filter.extensions.join(",")}`); - } - if (envelope.filter?.fileTypes?.length) { - parts.push(`file_types=${envelope.filter.fileTypes.join(",")}`); - } - if (envelope.filter?.languages?.length) { - parts.push(`languages=${envelope.filter.languages.join(",")}`); - } - if (envelope.filter?.fileIntent) { - parts.push(`file_intent=${envelope.filter.fileIntent}`); - } - if (envelope.filter?.fileIntents?.length) { - parts.push(`file_intents=${envelope.filter.fileIntents.join(",")}`); - } - if (envelope.filter?.excludeFileIntents?.length) { - parts.push( - `exclude_file_intents=${envelope.filter.excludeFileIntents.join(",")}`, - ); - } - if (envelope.filter?.excludeDocFiles !== undefined) { - parts.push(`exclude_doc_files=${String(envelope.filter.excludeDocFiles)}`); - } - if (envelope.filter?.excludeTestFiles !== undefined) { - parts.push( - `exclude_test_files=${String(envelope.filter.excludeTestFiles)}`, - ); - } - if (envelope.filter?.includeHidden !== undefined) { - parts.push(`include_hidden=${String(envelope.filter.includeHidden)}`); - } - if (envelope.filter?.limit !== undefined) { - parts.push(`limit=${envelope.filter.limit}`); - } - return parts.join(" "); -} - -function quote(value: string): string { - return value.includes('"') ? `'${value}'` : `"${value}"`; -} diff --git a/packages/mcp/src/shared/list-package-docs-response.test.ts b/packages/mcp/src/shared/list-package-docs-response.test.ts index 51d0d95e..d7c7830d 100644 --- a/packages/mcp/src/shared/list-package-docs-response.test.ts +++ b/packages/mcp/src/shared/list-package-docs-response.test.ts @@ -4,7 +4,6 @@ import { buildListPackageDocsSuccessPayload, formatListPackageDocsTerminal, } from "./list-package-docs-response.js"; -import { renderListPackageDocsText } from "./list-package-docs-text.js"; function buildEnvelope( overrides: Partial, @@ -27,36 +26,14 @@ describe("package docs list lifecycle output", () => { const envelope = buildEnvelope({ codeIndexState: "PENDING" }); expect(envelope.codeIndexState).toBe("PENDING"); - const mcp = renderListPackageDocsText(envelope); const cli = formatListPackageDocsTerminal(envelope, { useColors: false }); - for (const output of [mcp, cli]) { - expect(output).toContain("No documentation pages yet."); - expect(output).toContain("preparation is still in progress"); - expect(output).not.toContain("No documentation pages found."); - } - expect(mcp).toContain('`docs_list target="npm:express@5.2.1"`'); + expect(cli).toContain("No documentation pages yet."); + expect(cli).toContain("preparation is still in progress"); + expect(cli).not.toContain("No documentation pages found."); expect(cli).toContain("`githits docs list 'npm:express@5.2.1'`"); }); - it.each([ - ["express", undefined, "npm:express"], - ["@types/node", "22.0.0", "npm:@types/node@22.0.0"], - ] as const)( - "keeps the %s retry target callable", - (packageName, version, target) => { - const envelope = buildEnvelope({ - codeIndexState: "PENDING", - packageName, - version, - }); - - expect(renderListPackageDocsText(envelope)).toContain( - `\`docs_list target=${JSON.stringify(target)}\``, - ); - }, - ); - it("retains pages while marking a provisional snapshot", () => { const envelope = buildEnvelope({ codeIndexState: "PROVISIONAL", @@ -71,15 +48,11 @@ describe("package docs list lifecycle output", () => { pageInfo: { hasNextPage: false, totalCount: 1 }, }); - const mcp = renderListPackageDocsText(envelope); const cli = formatListPackageDocsTerminal(envelope, { useColors: false }); - expect(mcp.split("\n")[0]).toEndWith("| provisional"); expect(cli.split("\n")[0]).toEndWith("| provisional"); - for (const output of [mcp, cli]) { - expect(output).toContain("Guide"); - expect(output).toContain("provisional"); - expect(output).toContain("indexing is still in progress"); - } + expect(cli).toContain("Guide"); + expect(cli).toContain("provisional"); + expect(cli).toContain("indexing is still in progress"); }); it("marks non-empty indexing results and provides a later retry", () => { @@ -96,41 +69,30 @@ describe("package docs list lifecycle output", () => { pageInfo: { hasNextPage: false, totalCount: 1 }, }); - for (const output of [ - renderListPackageDocsText(envelope), - formatListPackageDocsTerminal(envelope, { useColors: false }), - ]) { - expect(output.split("\n")[0]).toEndWith("| indexing"); - expect(output).toContain("indexing is still in progress"); - expect(output).toContain("later for a current snapshot"); - } + const cli = formatListPackageDocsTerminal(envelope, { useColors: false }); + expect(cli.split("\n")[0]).toEndWith("| indexing"); + expect(cli).toContain("indexing is still in progress"); + expect(cli).toContain("later for a current snapshot"); }); it("does not call an empty provisional snapshot not found", () => { const envelope = buildEnvelope({ codeIndexState: "PROVISIONAL" }); - for (const output of [ - renderListPackageDocsText(envelope), - formatListPackageDocsTerminal(envelope, { useColors: false }), - ]) { - expect(output).toContain("No documentation pages yet."); - expect(output).toContain("indexing is still in progress"); - expect(output).not.toContain("No documentation pages found."); - } + const cli = formatListPackageDocsTerminal(envelope, { useColors: false }); + expect(cli).toContain("No documentation pages yet."); + expect(cli).toContain("indexing is still in progress"); + expect(cli).not.toContain("No documentation pages found."); }); it("keeps completed empty output terminal", () => { const envelope = buildEnvelope({ codeIndexState: "CURRENT" }); - expect(renderListPackageDocsText(envelope)).toContain( - "No documentation pages found.", - ); expect( formatListPackageDocsTerminal(envelope, { useColors: false }), ).toContain("No documentation pages found."); }); - it("renders one canonical action per hosted and repo page on both surfaces", () => { + it("renders one canonical CLI action per hosted and repo page", () => { const hostedTarget = "https://docs.example.test/guide"; const repoTarget = "github:owner/repo@immutable-sha/README.md"; const envelope = buildEnvelope({ @@ -156,15 +118,8 @@ describe("package docs list lifecycle output", () => { ], pageInfo: { hasNextPage: false, totalCount: 2 }, }); - const mcp = renderListPackageDocsText(envelope); const cli = formatListPackageDocsTerminal(envelope, { useColors: false }); - expect( - mcp.split("\n").filter((line) => line.startsWith(" read target=")), - ).toEqual([ - ` read target=${JSON.stringify(hostedTarget)}`, - ` read target=${JSON.stringify(repoTarget)}`, - ]); expect( cli.split("\n").filter((line) => line.startsWith(" githits read ")), ).toEqual([ diff --git a/packages/mcp/src/shared/list-package-docs-text.ts b/packages/mcp/src/shared/list-package-docs-text.ts deleted file mode 100644 index 407592c8..00000000 --- a/packages/mcp/src/shared/list-package-docs-text.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - isPackageDocsActive, - type LeanPackageDocsEnvelope, - packageDocsLifecycleLabel, - packageDocsProgressDescription, -} from "./list-package-docs-response.js"; -import { renderReadTarget } from "./read-target-text.js"; - -const SEP = " | "; - -export function renderListPackageDocsText( - envelope: LeanPackageDocsEnvelope, -): string { - const lines: string[] = []; - lines.push(buildHeader(envelope)); - lines.push(""); - - if (envelope.pages.length === 0) { - lines.push( - isPackageDocsActive(envelope) - ? "No documentation pages yet." - : "No documentation pages found.", - ); - if (isPackageDocsActive(envelope)) { - lines.push( - `Documentation ${packageDocsProgressDescription(envelope)} is still in progress. Retry ${buildMcpDocsListCall(envelope)} later.`, - ); - } - return lines.join("\n"); - } - - for (const page of envelope.pages) { - lines.push( - [ - page.pageId, - page.title ?? "", - page.sourceKind ?? "", - page.sourceUrl ?? "", - ].join(SEP), - ); - lines.push(` ${renderReadTarget({ target: page.docsReadTarget })}`); - } - - if (envelope.nextCursor) { - lines.push(""); - lines.push(`More docs available. Pass after=${envelope.nextCursor}.`); - } - if (envelope.stale) { - lines.push(""); - lines.push("Documentation may be stale."); - } - if (isPackageDocsActive(envelope)) { - lines.push(""); - lines.push( - `Documentation ${packageDocsProgressDescription(envelope)} is still in progress. Retry ${buildMcpDocsListCall(envelope)} later for a current snapshot.`, - ); - } - return lines.join("\n"); -} - -function buildHeader(envelope: LeanPackageDocsEnvelope): string { - const target = - envelope.registry && envelope.name - ? `${envelope.registry}:${envelope.name}${envelope.version ? `@${envelope.version}` : ""}` - : "package docs"; - const suffix = envelope.total !== undefined ? `/${envelope.total}` : ""; - const lifecycle = packageDocsLifecycleLabel(envelope); - return `docs_list${SEP}${target}${SEP}${envelope.pages.length}${suffix} page${envelope.pages.length === 1 ? "" : "s"}${lifecycle ? `${SEP}${lifecycle}` : ""}`; -} - -function buildMcpDocsListCall(envelope: LeanPackageDocsEnvelope): string { - const target = `${envelope.registry ?? ""}:${envelope.name ?? ""}${envelope.version ? `@${envelope.version}` : ""}`; - return `\`docs_list target=${JSON.stringify(target)}\``; -} diff --git a/packages/mcp/src/shared/list-text.test.ts b/packages/mcp/src/shared/list-text.test.ts index a5ce040a..346c99ac 100644 --- a/packages/mcp/src/shared/list-text.test.ts +++ b/packages/mcp/src/shared/list-text.test.ts @@ -73,10 +73,25 @@ describe("formatListText", () => { "docs/", "examples/", "README.md", + "", + "More results: reuse the same target, paths, and options with:", + " --after 'opaque-cursor'", ].join("\n"), ); }); + it("renders an MCP continuation with the opaque cursor", () => { + const result = sourceResult({ + entries: [entry("FILE", "src/index.ts")], + hasMore: true, + nextCursor: 'opaque "cursor"', + }); + + expect(formatListText(result, { syntax: "mcp" })).toEndWith( + 'More results: reuse the same target, paths, and options with:\n after="opaque \\"cursor\\""', + ); + }); + it("uses exact site read paths and relative site directories", () => { const result = siteResult({ requestedTarget: "site:legacy.example.test/api", diff --git a/packages/mcp/src/shared/list-text.ts b/packages/mcp/src/shared/list-text.ts index 0c4d0e39..4b93cc9d 100644 --- a/packages/mcp/src/shared/list-text.ts +++ b/packages/mcp/src/shared/list-text.ts @@ -1,9 +1,11 @@ import type { ListEntry, ListResult } from "@githits/core-internal"; import { dim } from "./colors.js"; +import { shellQuoteExact } from "./shell-quote.js"; export interface FormatListTextOptions { useColors?: boolean; includeHeader?: boolean; + syntax?: "cli" | "mcp"; } /** Render one token-efficient inventory shared by CLI and MCP text surfaces. */ @@ -16,10 +18,23 @@ export function formatListText( formatPath(entry, result.inventoryKind), ); if (options.includeHeader === false) return paths.join("\n"); - return [ + const lines = [ formatHeader(result, siteReadTarget, options.useColors === true), ...paths, - ].join("\n"); + ]; + if (result.nextCursor) { + const continuation = [ + "More results: reuse the same target, paths, and options with:", + options.syntax === "mcp" + ? ` after=${JSON.stringify(result.nextCursor)}` + : ` --after ${shellQuoteExact(result.nextCursor)}`, + ]; + lines.push( + "", + ...continuation.map((line) => dim(line, options.useColors === true)), + ); + } + return lines.join("\n"); } function formatHeader( diff --git a/packages/mcp/src/shared/read-file-error.ts b/packages/mcp/src/shared/read-file-error.ts index 44897de6..d443c66b 100644 --- a/packages/mcp/src/shared/read-file-error.ts +++ b/packages/mcp/src/shared/read-file-error.ts @@ -48,8 +48,8 @@ function buildReadFileNotFoundAction( : "With path, `read` reads files only, not directories. "; const listing = prefix === "" - ? "Use `code_files` without `path_prefix`" - : `Use \`code_files\` with \`path_prefix: ${JSON.stringify(prefix)}\``; + ? "Use `list` without `paths`" + : `Use \`list\` with \`paths: ${JSON.stringify([prefix])}\``; return ( `${preamble}${listing} to list valid indexed paths, then ` + "pass an emitted `path` back to `read`." diff --git a/packages/mcp/src/shared/read-file-request.test.ts b/packages/mcp/src/shared/read-file-request.test.ts index 70a5eb8c..c6359440 100644 --- a/packages/mcp/src/shared/read-file-request.test.ts +++ b/packages/mcp/src/shared/read-file-request.test.ts @@ -35,7 +35,7 @@ describe("buildReadFileParams — defaults and validation", () => { it("rejects directory prefixes before they reach the backend", () => { expect(() => buildReadFileParams({ target, filePath: "lib/" })).toThrow( - /code_files.*path_prefix: "lib\/"/, + /`list` with `paths: \["lib\/"\]`/, ); }); diff --git a/packages/mcp/src/shared/read-file-request.ts b/packages/mcp/src/shared/read-file-request.ts index 0a1a12be..b94acf86 100644 --- a/packages/mcp/src/shared/read-file-request.ts +++ b/packages/mcp/src/shared/read-file-request.ts @@ -26,8 +26,8 @@ export interface ReadFileRequestBuildResult { params: ReadFileParams; } -// CLI rewrites MCP identifiers from these errors, including the raw reversed-range -// labels; keep them stable with src/commands/code/read.ts or update its tests. +// CLI translates these shared validation errors to native command syntax; +// keep their stable wording aligned with src/commands/code/read.ts tests. export function buildReadFileParams( input: ReadFileRequestInput, ): ReadFileRequestBuildResult { @@ -39,7 +39,7 @@ export function buildReadFileParams( } if (filePath.endsWith("/")) { throw new InvalidPackageSpecError( - `\`file_path\` must be an exact file path, not a directory prefix. Use \`code_files\` with \`path_prefix: ${JSON.stringify(filePath)}\` to list files, then pass an emitted \`path\` to \`read\`.`, + `\`file_path\` must be an exact file path, not a directory prefix. Use \`list\` with \`paths: ${JSON.stringify([filePath])}\` to list files, then pass an emitted \`path\` to \`read\`.`, ); } diff --git a/packages/mcp/src/smoke-test.test.ts b/packages/mcp/src/smoke-test.test.ts index 54ee80cb..c011750e 100644 --- a/packages/mcp/src/smoke-test.test.ts +++ b/packages/mcp/src/smoke-test.test.ts @@ -271,7 +271,7 @@ describe("runMcpSmoke", () => { ); expect(calls.some(({ name }) => name === "feedback")).toBe(false); const compactPackageNames = new Set([ - "docs_list", + "list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -289,6 +289,73 @@ describe("runMcpSmoke", () => { expect(args, `${name} package_name`).not.toHaveProperty("package_name"); expect(args, `${name} version`).not.toHaveProperty("version"); } + expect(calls).toContainEqual({ + name: "list", + args: { + target: SMOKE_PACKAGE_TARGET, + paths: ["package.json"], + limit: 1, + }, + }); + expect(calls).toContainEqual({ + name: "list", + args: { + target: SMOKE_PACKAGE_TARGET, + paths: ["package.json"], + limit: 1, + format: "json", + }, + }); + expect(calls).toContainEqual({ + name: "list", + args: { + target: SMOKE_PACKAGE_TARGET, + limit: 1, + }, + }); + expect(calls).toContainEqual({ + name: "list", + args: { + target: SMOKE_PACKAGE_TARGET, + limit: 1, + after: SMOKE_LIST_CURSOR, + }, + }); + expect(calls).toContainEqual({ + name: "list", + args: { + target: SMOKE_SITE_TARGET, + paths: [SMOKE_SITE_PAGE_PATH], + limit: 20, + }, + }); + expect(calls).toContainEqual({ + name: "list", + args: { + target: SMOKE_SITE_TARGET, + paths: [SMOKE_SITE_PAGE_PATH], + limit: 20, + format: "json", + }, + }); + expect(calls).toContainEqual({ + name: "read", + args: { + target: SMOKE_PACKAGE_TARGET, + path: "package.json", + start_line: 1, + end_line: 5, + }, + }); + expect(calls).toContainEqual({ + name: "read", + args: { + target: SMOKE_SITE_TARGET, + path: SMOKE_SITE_PAGE_PATH, + start_line: 1, + end_line: 5, + }, + }); expect(calls).toContainEqual({ name: "pkg_deps", args: { @@ -331,11 +398,11 @@ describe("runMcpSmoke", () => { { type: "documentation_page", locator: { - docsReadTarget: SMOKE_CRAWLED_DOC_TARGET, + docsReadTarget: SMOKE_SITE_PAGE_URL, startLine: 81, endLine: 93, }, - followUp: `read target=${JSON.stringify(SMOKE_CRAWLED_DOC_TARGET)} start_line=81 end_line=93`, + followUp: `read target=${JSON.stringify(SMOKE_SITE_PAGE_URL)} start_line=81 end_line=93`, }, ], }); @@ -366,8 +433,8 @@ describe("runMcpSmoke", () => { results: [ { type: "documentation_page", - locator: { docsReadTarget: SMOKE_CRAWLED_DOC_TARGET }, - followUp: `read target=${JSON.stringify(SMOKE_CRAWLED_DOC_TARGET)}`, + locator: { docsReadTarget: SMOKE_SITE_PAGE_URL }, + followUp: `read target=${JSON.stringify(SMOKE_SITE_PAGE_URL)}`, }, ], }, @@ -893,10 +960,77 @@ describe("runMcpSmoke", () => { }); }); -const SMOKE_CRAWLED_DOC_TARGET = "https://expressjs.com/en/guide/routing.html"; -const SMOKE_CRAWLED_DOC_ID = "legacy-routing-id"; -const SMOKE_REPO_SHA = "0123456789abcdef0123456789abcdef01234567"; -const SMOKE_REPO_DOC_ID = `github:expressjs/express@${SMOKE_REPO_SHA}/README.md`; +const SMOKE_SITE_TARGET = "site:expressjs.com"; +const SMOKE_SITE_PAGE_PATH = "en/resources/"; +const SMOKE_SITE_PAGE_URL = "https://expressjs.com/en/resources/"; +const SMOKE_PACKAGE_TARGET = "npm:express@5.2.1"; +const SMOKE_PACKAGE_VERSION = "5.2.1"; +const SMOKE_LIST_CURSOR = "smoke-list-cursor"; + +function smokeListResult( + args: Record, +): Record { + const target = args.target; + if (typeof target !== "string") { + throw new Error("list smoke requires a target"); + } + const isSite = target.startsWith("site:"); + const isRootPackageQuery = !isSite && args.paths === undefined; + const hasMore = + isRootPackageQuery && args.limit === 1 && args.after !== SMOKE_LIST_CURSOR; + const packagePath = isRootPackageQuery + ? args.after === SMOKE_LIST_CURSOR + ? "index.js" + : "History.md" + : "package.json"; + return { + inventoryKind: isSite ? "SITE" : "SOURCE", + requestedTarget: target, + canonicalTarget: target, + entries: isSite + ? [ + { + kind: "PAGE", + path: SMOKE_SITE_PAGE_PATH, + read: { target: SMOKE_SITE_TARGET, path: SMOKE_SITE_PAGE_PATH }, + }, + ] + : [ + { + kind: "FILE", + path: packagePath, + read: { target: SMOKE_PACKAGE_TARGET, path: packagePath }, + }, + ], + hasMore, + nextCursor: hasMore ? SMOKE_LIST_CURSOR : null, + indexedVersion: isSite ? null : SMOKE_PACKAGE_VERSION, + codeIndexState: null, + indexingStatus: null, + indexingRef: null, + inventoryState: "AVAILABLE", + crawlStatus: isSite ? "COMPLETE" : null, + coverageState: "COMPLETE", + coverageReason: null, + preparation: null, + }; +} + +function smokeListText(args: Record): string { + const result = smokeListResult(args); + const isSite = result.inventoryKind === "SITE"; + const source = result.canonicalTarget; + const followUp = isSite + ? ' | follow up with "read site:expressjs.com $path"' + : ""; + const more = result.hasMore ? " | more results available" : ""; + const entries = result.entries as Array>; + const path = entries[0]?.path; + const continuation = result.nextCursor + ? `\n\nMore results: reuse the same target, paths, and options with:\n after=${JSON.stringify(result.nextCursor)}` + : ""; + return `# source ${String(source)}${followUp}${more}\n${path}${continuation}`; +} function smokeResponse( name: string, @@ -979,19 +1113,12 @@ function smokeResponse( "Changes\n" + " Repository releases | 1 entry | 1 with release notes", ); - case "docs_list": - if (args.after !== "smoke-doc-cursor") { - throw new Error("docs_list text smoke missing crawled-page cursor"); - } - return textResult( - `read target=${JSON.stringify(SMOKE_CRAWLED_DOC_TARGET)}`, - ); + case "list": + return textResult(smokeListText(args)); case "read": return textResult( args.path ? '1 {"name":"express"}' : "documentation content", ); - case "code_files": - return textResult("package.json"); case "code_grep": return textResult( "package.json: express\nContext limited (requested 0 / 12)", @@ -1096,37 +1223,18 @@ function smokeJsonResponse( return jsonResult({ entries: {} }); case "pkg_upgrade_review": return jsonResult({ summary: {}, reviews: [{}] }); - case "docs_list": - return jsonResult({ - pages: [ - { - docsReadTarget: SMOKE_REPO_DOC_ID, - pageId: SMOKE_REPO_DOC_ID, - sourceKind: "repo", - sourceUrl: `https://github.com/expressjs/express/blob/${SMOKE_REPO_SHA}/README.md`, - }, - ...(args.limit === 1 - ? [] - : [ - { - docsReadTarget: SMOKE_CRAWLED_DOC_TARGET, - pageId: SMOKE_CRAWLED_DOC_ID, - sourceKind: "crawled", - sourceUrl: SMOKE_CRAWLED_DOC_TARGET, - }, - ]), - ], - ...(args.limit === 1 ? { nextCursor: "smoke-doc-cursor" } : {}), - }); + case "list": + return jsonResult(smokeListResult(args)); case "read": { if ( - args.target === "site:expressjs.com" && - args.path === "en/resources" + (args.target === SMOKE_SITE_TARGET && + args.path === SMOKE_SITE_PAGE_PATH) || + args.target === SMOKE_SITE_PAGE_URL ) { return jsonResult({ - docsReadTarget: "https://expressjs.com/en/resources/", + docsReadTarget: SMOKE_SITE_PAGE_URL, pageId: "express-resources", - sourceUrl: "https://expressjs.com/en/resources/", + sourceUrl: SMOKE_SITE_PAGE_URL, content: "documentation content", startLine: 1, endLine: 1, @@ -1139,28 +1247,16 @@ function smokeJsonResponse( ) { return errorResult("NOT_FOUND"); } - if (args.target === SMOKE_REPO_DOC_ID) { - return jsonResult({ - path: "README.md", - content: "documentation content", - startLine: 1, - endLine: 1, - totalLines: 1, - targetResolution: { served: { commitSha: SMOKE_REPO_SHA } }, - }); - } return jsonResult({ - docsReadTarget: SMOKE_CRAWLED_DOC_TARGET, - pageId: SMOKE_CRAWLED_DOC_ID, - sourceUrl: SMOKE_CRAWLED_DOC_TARGET, + docsReadTarget: SMOKE_SITE_PAGE_URL, + pageId: "express-resources", + sourceUrl: SMOKE_SITE_PAGE_URL, content: "documentation content", startLine: 1, endLine: 1, totalLines: 1, }); } - case "code_files": - return jsonResult({ files: [{ path: "package.json" }] }); case "code_grep": return jsonResult({ matches: [], @@ -1205,12 +1301,12 @@ function smokeJsonResponse( { type: "documentation_page", locator: { - docsReadTarget: SMOKE_CRAWLED_DOC_TARGET, - sourceUrl: SMOKE_CRAWLED_DOC_TARGET, + docsReadTarget: SMOKE_SITE_PAGE_URL, + sourceUrl: SMOKE_SITE_PAGE_URL, startLine: 81, endLine: 93, }, - followUp: `read target=${JSON.stringify(SMOKE_CRAWLED_DOC_TARGET)}`, + followUp: `read target=${JSON.stringify(SMOKE_SITE_PAGE_URL)}`, }, ], }); diff --git a/packages/mcp/src/smoke-test.ts b/packages/mcp/src/smoke-test.ts index 9b04e726..28c5f463 100644 --- a/packages/mcp/src/smoke-test.ts +++ b/packages/mcp/src/smoke-test.ts @@ -47,17 +47,16 @@ interface TextContent { export const EXPECTED_MCP_TOOLS = [ "quick_start", "get_example", + "search", + "search_status", + "list", + "read", + "code_grep", "pkg_info", - "pkg_deps", "pkg_vulns", + "pkg_deps", "pkg_changelog", "pkg_upgrade_review", - "docs_list", - "code_files", - "read", - "code_grep", - "search", - "search_status", ] as const; const DEFAULT_TEXT_LIMIT = 12_000; @@ -244,6 +243,24 @@ export function assertDefaultText( return text; } +function listTextFirstPath(text: string, context: string): string { + const [header, path] = text.split("\n"); + assert(header?.startsWith("# source "), `${context}: missing source header`); + assert(path !== undefined && path.length > 0, `${context}: missing path`); + return path; +} + +function listTextContinuation(text: string, context: string): string { + const line = text.split("\n").find((value) => value.startsWith(" after=")); + assert(line !== undefined, `${context}: missing after continuation`); + const parsed = parseJson(line.slice(" after=".length), context); + assert( + typeof parsed === "string" && parsed.length > 0, + `${context}: invalid after continuation`, + ); + return parsed; +} + function assertSearchDefaultText(text: string, context: string): void { const lines = text.split("\n"); let pathOnlyBlock = false; @@ -1074,192 +1091,224 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { ); } - const docsJson = assertJsonResult( - await callTool(caller, "docs_list", { - target: SMOKE_PACKAGE_TARGET, - limit: 500, - format: "json", - }), - "docs_list json", - ); - assertRecord(docsJson, "docs_list json"); - assert(Array.isArray(docsJson.pages), "docs_list json missing pages array"); - const docsPages = docsJson.pages as unknown[]; - const crawledPage = docsPages.find( - (page) => - typeof page === "object" && - page !== null && - (page as Record).sourceKind === "crawled" && - typeof (page as Record).docsReadTarget === "string" && - /^https?:\/\//.test( - (page as Record).docsReadTarget as string, - ), - ) as Record | undefined; - const repoPage = docsPages.find( - (page) => - typeof page === "object" && - page !== null && - (page as Record).sourceKind === "repo", + const packageListArgs = { + target: SMOKE_PACKAGE_TARGET, + paths: ["package.json"], + limit: 1, + }; + const packageListText = assertDefaultText( + await callTool(caller, "list", packageListArgs), + "list package default", + ); + assert( + packageListText.includes("package.json"), + "list package default missing package.json", + ); + + const packageListJson = assertJsonResult( + await callTool(caller, "list", { ...packageListArgs, format: "json" }), + "list package json", + ); + assertRecord(packageListJson, "list package json"); + assert( + packageListJson.inventoryKind === "SOURCE" && + packageListJson.requestedTarget === SMOKE_PACKAGE_TARGET && + Array.isArray(packageListJson.entries), + "list package json missing source inventory identity or entries", + ); + const packageEntries = packageListJson.entries as unknown[]; + const packageEntry = packageEntries.find( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as Record).path === "package.json", ) as Record | undefined; + assert(packageEntry, "list package json missing package.json entry"); + const packageRead = packageEntry.read; + assertRecord(packageRead, "list package json read action"); + const packageReadTarget = packageRead.target; + const packageReadPath = packageRead.path; assert( - crawledPage && - typeof crawledPage.docsReadTarget === "string" && - typeof crawledPage.pageId === "string" && - typeof crawledPage.sourceUrl === "string", - "docs_list json missing crawled URL target, compatible page ID, or source URL", + typeof packageReadTarget === "string" && + typeof packageReadPath === "string", + "list package json entry missing read target or path", ); assert( - repoPage && - typeof repoPage.docsReadTarget === "string" && - typeof repoPage.pageId === "string" && - typeof repoPage.sourceUrl === "string", - "docs_list json missing repo-backed target, compatible page ID, or source URL", + typeof packageListJson.hasMore === "boolean" && + packageListJson.hasMore === + (typeof packageListJson.nextCursor === "string" && + packageListJson.nextCursor.length > 0) && + (packageListJson.hasMore || packageListJson.nextCursor === null), + "list package json hasMore/nextCursor mismatch", + ); + const rootListArgs = { target: SMOKE_PACKAGE_TARGET, limit: 1 }; + const firstRootPage = assertDefaultText( + await callTool(caller, "list", rootListArgs), + "list package root first page text", + ); + const firstRootPath = listTextFirstPath( + firstRootPage, + "list package root first page text", + ); + const nextCursor = listTextContinuation( + firstRootPage, + "list package root first page text", ); assert( - repoPage.docsReadTarget === repoPage.pageId, - "docs_list json repo-backed docsReadTarget should remain snapshot-pinned", + firstRootPage.includes("| more results available") && + firstRootPath.length > 0 && + nextCursor.length > 0, + "list package root first page must expose one path and a text continuation", ); - const crawledPageIndex = docsPages.indexOf(crawledPage); - let crawledPageAfter: string | undefined; - if (crawledPageIndex > 0) { - const precedingDocs = assertJsonResult( - await callTool(caller, "docs_list", { - target: SMOKE_PACKAGE_TARGET, - limit: crawledPageIndex, - format: "json", - }), - "docs_list crawled target cursor", - ); - assertRecord(precedingDocs, "docs_list crawled target cursor"); - assert( - typeof precedingDocs.nextCursor === "string", - "docs_list crawled target cursor missing nextCursor", - ); - crawledPageAfter = precedingDocs.nextCursor; - } - const docsText = assertDefaultText( - await callTool(caller, "docs_list", { - target: SMOKE_PACKAGE_TARGET, - limit: 1, - ...(crawledPageAfter ? { after: crawledPageAfter } : {}), + const secondRootPage = assertDefaultText( + await callTool(caller, "list", { + ...rootListArgs, + after: nextCursor, }), - "docs_list crawled target default", + "list package root continuation text", + ); + const secondRootPath = listTextFirstPath( + secondRootPage, + "list package root continuation text", ); assert( - docsText.includes( - `read target=${JSON.stringify(crawledPage.docsReadTarget)}`, - ), - "docs_list default missing crawled URL follow-up", + secondRootPath.length > 0 && secondRootPath !== firstRootPath, + "list package root continuation repeated its first entry", ); - const docReadText = assertDefaultText( + const packageReadText = assertDefaultText( await callTool(caller, "read", { - target: crawledPage.docsReadTarget, + target: packageReadTarget, + path: packageReadPath, start_line: 1, end_line: 5, }), - "read crawled URL default", + "read package list action default", ); - assert(docReadText.length > 0, "read crawled URL default missing content"); - - const docReadJson = assertJsonResult( + assert( + /^1\s+/m.test(packageReadText), + "read package list action default missing line numbers", + ); + const packageReadJson = assertJsonResult( await callTool(caller, "read", { - target: crawledPage.docsReadTarget, + target: packageReadTarget, + path: packageReadPath, start_line: 1, end_line: 5, format: "json", }), - "read crawled URL json", + "read package list action json", ); - assertRecord(docReadJson, "read crawled URL json"); + assertRecord(packageReadJson, "read package list action json"); assert( - docReadJson.docsReadTarget === crawledPage.docsReadTarget && - docReadJson.pageId === crawledPage.pageId && - docReadJson.sourceUrl === crawledPage.sourceUrl && - typeof docReadJson.content === "string" && - docReadJson.startLine === 1 && - typeof docReadJson.endLine === "number" && - docReadJson.endLine >= 1 && - docReadJson.endLine <= 5 && - typeof docReadJson.totalLines === "number" && - docReadJson.totalLines >= docReadJson.endLine, - "read crawled URL json missing locators, content, or backend range", + packageReadJson.path === packageReadPath, + "read package list action json path mismatch", ); - const sitePathReadText = assertDefaultText( - await callTool(caller, "read", { - target: "site:expressjs.com", - path: "en/resources", - start_line: 1, - end_line: 5, - }), - "read site path default", + const siteListArgs = { + target: "site:expressjs.com", + paths: ["en/resources/"], + limit: 20, + }; + const siteListText = assertDefaultText( + await callTool(caller, "list", siteListArgs), + "list site default", ); - assert(sitePathReadText.length > 0, "read site path default missing content"); - - const sitePathReadJson = assertJsonResult( - await callTool(caller, "read", { - target: "site:expressjs.com", - path: "en/resources", - start_line: 1, - end_line: 5, - format: "json", - }), - "read site path json", + assert( + siteListText.includes( + '# source site:expressjs.com | follow up with "read site:expressjs.com $path"', + ) && siteListText.includes("en/resources/"), + "list site default missing follow-up header or resources path", ); - assertRecord(sitePathReadJson, "read site path json"); + const siteListJson = assertJsonResult( + await callTool(caller, "list", { ...siteListArgs, format: "json" }), + "list site json", + ); + assertRecord(siteListJson, "list site json"); assert( - typeof sitePathReadJson.content === "string" && - sitePathReadJson.startLine === 1 && - typeof sitePathReadJson.endLine === "number" && - sitePathReadJson.endLine >= 1 && - sitePathReadJson.endLine <= 5 && - typeof sitePathReadJson.totalLines === "number" && - sitePathReadJson.totalLines >= sitePathReadJson.endLine, - "read site path json missing content or backend range", + siteListJson.inventoryKind === "SITE" && + siteListJson.requestedTarget === siteListArgs.target && + Array.isArray(siteListJson.entries), + "list site json missing site inventory identity or entries", ); + const siteEntries = siteListJson.entries as unknown[]; + const sitePage = siteEntries.find( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as Record).kind === "PAGE", + ) as Record | undefined; + assert(sitePage, "list site json missing PAGE entry"); + const siteRead = sitePage.read; + assertRecord(siteRead, "list site json PAGE read action"); + const siteReadTarget = siteRead.target; + const siteReadPath = siteRead.path; + assert( + typeof siteReadTarget === "string" && typeof siteReadPath === "string", + "list site json PAGE missing read target or path", + ); + for (const [format, label] of [ + [undefined, "default"], + ["json", "json"], + ] as const) { + const result = await callTool(caller, "read", { + target: siteReadTarget, + path: siteReadPath, + start_line: 1, + end_line: 5, + ...(format ? { format } : {}), + }); + if (format) { + const value = assertJsonResult(result, `read site list action ${label}`); + assertRecord(value, `read site list action ${label}`); + assert( + typeof value.content === "string" && + value.startLine === 1 && + typeof value.endLine === "number" && + value.endLine >= 1 && + value.endLine <= 5 && + typeof value.totalLines === "number" && + value.totalLines >= value.endLine, + `read site list action ${label} missing content or backend range`, + ); + } else { + const text = assertDefaultText(result, `read site list action ${label}`); + assert(text.length > 0, `read site list action ${label} missing content`); + } + } - const legacyCrawledRead = assertJsonResult( + const directUrlReadText = assertDefaultText( await callTool(caller, "read", { - target: crawledPage.pageId, + target: "https://expressjs.com/en/resources/", start_line: 1, end_line: 5, - format: "json", }), - "read legacy crawled ID json", + "read exact site URL default", ); - assertRecord(legacyCrawledRead, "read legacy crawled ID json"); assert( - legacyCrawledRead.pageId === docReadJson.pageId && - legacyCrawledRead.content === docReadJson.content, - "read URL and legacy crawled ID returned different ranged content", + directUrlReadText.length > 0, + "read exact site URL default missing content", ); - - const repoRead = assertJsonResult( + const directUrlReadJson = assertJsonResult( await callTool(caller, "read", { - target: repoPage.docsReadTarget, + target: "https://expressjs.com/en/resources/", + start_line: 1, + end_line: 5, format: "json", }), - "read repo-backed ID json", - ); - assertRecord(repoRead, "read repo-backed ID json"); - const snapshotMatch = /@([a-f0-9]{40})\/(.+)$/i.exec(repoPage.docsReadTarget); - assert(snapshotMatch, "repo-backed ID must contain a snapshot file path"); - assertRecord(repoRead.targetResolution, "read repo-backed ID resolution"); - assertRecord( - repoRead.targetResolution.served, - "read repo-backed ID served resolution", + "read exact site URL json", ); + assertRecord(directUrlReadJson, "read exact site URL json"); assert( - repoRead.path === snapshotMatch[2] && - repoRead.targetResolution.served.commitSha === snapshotMatch[1] && - typeof repoRead.content === "string" && - typeof repoRead.totalLines === "number" && - (repoRead.totalLines === 0 || - (typeof repoRead.startLine === "number" && - typeof repoRead.endLine === "number")), - "read repo-backed ID json missing indexed file identity, content, or range", + typeof directUrlReadJson.content === "string" && + directUrlReadJson.startLine === 1 && + typeof directUrlReadJson.endLine === "number" && + directUrlReadJson.endLine >= 1 && + directUrlReadJson.endLine <= 5 && + typeof directUrlReadJson.totalLines === "number" && + directUrlReadJson.totalLines >= directUrlReadJson.endLine, + "read exact site URL json missing content or backend range", ); assertErrorCode( @@ -1271,58 +1320,6 @@ async function runLiveSmoke(caller: McpSmokeCaller): Promise { "NOT_FOUND", ); - const codeFilesText = assertDefaultText( - await callTool(caller, "code_files", { - target: SMOKE_PACKAGE_TARGET, - path_prefix: "package.json", - limit: 1, - }), - "code_files default", - ); - assert( - codeFilesText.includes("package.json"), - "code_files default missing package.json", - ); - - const codeFilesJson = assertJsonResult( - await callTool(caller, "code_files", { - target: SMOKE_PACKAGE_TARGET, - path_prefix: "package.json", - limit: 1, - format: "json", - }), - "code_files json", - ); - assertRecord(codeFilesJson, "code_files json"); - assert( - Array.isArray(codeFilesJson.files), - "code_files json missing files array", - ); - - const codeReadText = assertDefaultText( - await callTool(caller, "read", { - target: `npm:express@${SMOKE_PACKAGE_VERSION}`, - path: "package.json", - start_line: 1, - end_line: 5, - }), - "read default", - ); - assert(/^1\s+/m.test(codeReadText), "read default missing line numbers"); - - const codeReadJson = assertJsonResult( - await callTool(caller, "read", { - target: `npm:express@${SMOKE_PACKAGE_VERSION}`, - path: "package.json", - start_line: 1, - end_line: 5, - format: "json", - }), - "read json", - ); - assertRecord(codeReadJson, "read json"); - assert(codeReadJson.path === "package.json", "read json path mismatch"); - const codeGrepText = assertDefaultText( await callTool(caller, "code_grep", { target: SMOKE_PACKAGE_TARGET, diff --git a/packages/mcp/src/tools/grep-repo.test.ts b/packages/mcp/src/tools/grep-repo.test.ts index 9a9a5297..cc75f928 100644 --- a/packages/mcp/src/tools/grep-repo.test.ts +++ b/packages/mcp/src/tools/grep-repo.test.ts @@ -362,7 +362,8 @@ describe("createGrepRepoTool — validation errors", () => { const payload = parseText(result) as { code: string; error: string }; expect(payload.code).toBe("INVALID_ARGUMENT"); expect(payload.error).toContain("`pattern` is required"); - expect(payload.error).toContain("use `code_files` instead"); + expect(payload.error).toContain("use `list` instead"); + expect(payload.error).not.toContain("code_files"); }); it("returns INVALID_ARGUMENT for out-of-range numeric arguments", async () => { @@ -383,7 +384,7 @@ describe("createGrepRepoTool — validation errors", () => { }); describe("createGrepRepoTool — service errors", () => { - it("adds code_files recovery details for an exact missing path", async () => { + it("adds list recovery details for an exact missing path", async () => { const service = createMockCodeNavigationService({ grepRepo: mock(() => Promise.reject( @@ -411,8 +412,9 @@ describe("createGrepRepoTool — service errors", () => { }; expect(payload.code).toBe("FILE_NOT_FOUND"); expect(payload.details?.filePath).toBe("docs/missing.md"); - expect(payload.details?.action).toContain("`code_files`"); - expect(payload.details?.action).toContain('path_prefix: "docs/"'); + expect(payload.details?.action).toContain('`list` with `paths: ["docs/"]`'); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); expect(payload.details?.action).toContain("`code_grep`"); expect(payload.details?.action).not.toContain("githits code"); }); @@ -449,8 +451,11 @@ describe("createGrepRepoTool — service errors", () => { details?: { action?: string }; }; expect(payload.details?.action).toContain(expectedGuidance); - expect(payload.details?.action).toContain("`code_files`"); - expect(payload.details?.action).toContain('path_prefix: "bench/data/"'); + expect(payload.details?.action).toContain( + '`list` with `paths: ["bench/data/"]`', + ); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); expect(payload.details?.action).toContain("`code_grep`"); expect(payload.details?.action).not.toContain("githits code"); }, @@ -479,7 +484,9 @@ describe("createGrepRepoTool — service errors", () => { const payload = parseText(result) as { details?: { action?: string }; }; - expect(payload.details?.action).toContain('path_prefix: "benchmarks/"'); + expect(payload.details?.action).toContain( + '`list` with `paths: ["benchmarks/"]`', + ); expect(payload.details?.action).not.toContain("benchmarks/run/"); }); @@ -506,7 +513,9 @@ describe("createGrepRepoTool — service errors", () => { const payload = parseText(result) as { details?: { action?: string }; }; - expect(payload.details?.action).toContain("without `path_prefix`"); + expect(payload.details?.action).toContain("Use `list` without `paths`"); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); expect(payload.details?.action).not.toContain("LICENSE/"); }); diff --git a/packages/mcp/src/tools/grep-repo.ts b/packages/mcp/src/tools/grep-repo.ts index a38ee9f3..775e86c7 100644 --- a/packages/mcp/src/tools/grep-repo.ts +++ b/packages/mcp/src/tools/grep-repo.ts @@ -67,7 +67,7 @@ const schema: ZodRawShape = { .string() .optional() .describe( - "Literal directory prefix to scope grep, matching `code_files` / `search` naming.", + 'Literal directory prefix to scope grep. Pass the same directory to `list` with `paths: ["docs/"]` to enumerate it.', ), globs: z .array(z.string()) diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index a63d25b1..83242880 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,8 +1,7 @@ export { createGetExampleTool } from "./get-example.js"; export { createGrepRepoTool } from "./grep-repo.js"; export * from "./guardrails.js"; -export { createListFilesTool } from "./list-files.js"; -export { createListPackageDocsTool } from "./list-package-docs.js"; +export { createListTool } from "./list.js"; export { createPackageChangelogTool, DESCRIPTION as PACKAGE_CHANGELOG_DESCRIPTION, diff --git a/packages/mcp/src/tools/list-files.test.ts b/packages/mcp/src/tools/list-files.test.ts deleted file mode 100644 index 5907e4dc..00000000 --- a/packages/mcp/src/tools/list-files.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import { - CodeNavigationIndexingError, - CodeNavigationTargetNotFoundError, -} from "@githits/core-internal"; -import { z } from "zod"; -import { getMcpToolDescriptors } from "../mcp/server.js"; -import { - createMockCodeNavigationService, - defaultListFilesResult, -} from "../services/test-helpers.js"; -import { createListFilesTool } from "./list-files.js"; - -function parseText(result: { content: Array<{ text: string }> }): unknown { - return JSON.parse(result.content[0]?.text ?? ""); -} - -describe("createListFilesTool — metadata", () => { - it("documents canonical target guidance for package and repository scope", () => { - const descriptor = getMcpToolDescriptors().find( - (entry) => entry.name === "code_files", - ); - expect(descriptor).toBeDefined(); - const jsonSchema = z.toJSONSchema(z.object(descriptor?.schema ?? {})); - const targetSchema = JSON.stringify(jsonSchema.properties?.target); - - expect(targetSchema).toContain("Compact target"); - expect(targetSchema).toContain("npm:react@version"); - expect(targetSchema).toContain("github:facebook/react@ref"); - expect(targetSchema).toContain( - "Omit the suffix for the latest package version or repository default branch", - ); - expect(targetSchema).toContain("a ref may be a branch, tag, or commit"); - expect(targetSchema).toContain("#` is for semantic fragments"); - expect(targetSchema).toContain( - "Package targets scope to the package subpath; repository targets cover the full repository", - ); - expect(descriptor?.description.slice(0, 80)).toBe( - "List indexed files and paths in a public repo or package. Discover paths before ", - ); - }); - - it("registers the correct tool name, description, and schema keys", () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - expect(tool.name).toBe("code_files"); - expect(tool.description).toContain( - "List indexed files and paths in a public repo or package", - ); - expect(tool.description).toContain("`read.path` or to scope `code_grep`"); - expect(tool.description).toMatch(/\benumerat(?:e|ion)\b/i); - expect(tool.description).toContain( - "directory enumeration with `path_prefix`", - ); - expect(tool.description).toContain("`FILE_PATH_EXCLUDED`"); - expect(tool.description).toContain("`SOURCE_FILE_INVENTORY_UNKNOWN`"); - expect(Object.keys(tool.schema).sort()).toEqual([ - "exclude_doc_files", - "exclude_file_intents", - "exclude_test_files", - "extensions", - "file_intent", - "file_intents", - "file_types", - "format", - "globs", - "include_hidden", - "languages", - "limit", - "path", - "path_prefix", - "target", - "wait_timeout_ms", - ]); - expect(tool.annotations).toEqual({ - readOnlyHint: true, - openWorldHint: true, - destructiveHint: false, - }); - }); -}); - -describe("createListFilesTool — happy path", () => { - it("calls listFiles with the resolved package target", async () => { - const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); - const service = createMockCodeNavigationService({ listFiles }); - const tool = createListFilesTool(service); - - await tool.handler( - { - target: "npm:express", - }, - {}, - ); - - const calls = listFiles.mock.calls as unknown as Array< - [{ target: { registry?: string; packageName?: string } }] - >; - expect(calls[0]?.[0]?.target?.registry).toBe("NPM"); - expect(calls[0]?.[0]?.target?.packageName).toBe("express"); - }); - - it("ignores blank repo fields on package targets", async () => { - const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); - const service = createMockCodeNavigationService({ listFiles }); - const tool = createListFilesTool(service); - - await tool.handler( - { - target: "npm:express", - }, - {}, - ); - - const calls = listFiles.mock.calls as unknown as Array< - [ - { - target: { registry?: string; packageName?: string; repoUrl?: string }; - }, - ] - >; - expect(calls[0]?.[0]?.target).toMatchObject({ - registry: "NPM", - packageName: "express", - }); - expect(calls[0]?.[0]?.target?.repoUrl).toBeUndefined(); - }); - - it("accepts compact repo string targets", async () => { - const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); - const service = createMockCodeNavigationService({ listFiles }); - const tool = createListFilesTool(service); - - await tool.handler( - { - target: "https://github.com/expressjs/express@HEAD", - }, - {}, - ); - - const calls = listFiles.mock.calls as unknown as Array< - [{ target: { repoUrl?: string; gitRef?: string } }] - >; - expect(calls[0]?.[0]?.target).toMatchObject({ - repoUrl: "https://github.com/expressjs/express", - gitRef: "HEAD", - }); - }); - - it("forwards advanced list-files filters to the service", async () => { - const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); - const service = createMockCodeNavigationService({ listFiles }); - const tool = createListFilesTool(service); - - await tool.handler( - { - target: "npm:express", - path: "README.md", - path_prefix: "src/", - globs: ["test/**/*.js"], - extensions: ["js"], - file_types: ["source"], - languages: ["JavaScript"], - file_intents: ["production", "test"], - exclude_file_intents: ["generated"], - exclude_doc_files: true, - exclude_test_files: false, - include_hidden: true, - }, - {}, - ); - - const calls = listFiles.mock.calls as unknown as Array< - [ - { - pathSelectors?: Array<{ kind: string; value: string }>; - pathPrefix?: string; - fileIntents?: string[]; - excludeFileIntents?: string[]; - includeHidden?: boolean; - }, - ] - >; - expect(calls[0]?.[0]).toMatchObject({ - pathSelectors: [ - { kind: "EXACT", value: "README.md" }, - { kind: "GLOB", value: "test/**/*.js" }, - ], - pathPrefix: "src/", - fileIntents: ["PRODUCTION", "TEST"], - excludeFileIntents: ["GENERATED"], - includeHidden: true, - }); - }); - - it("emits the envelope with files, total, hasMore, resolution, indexedVersion", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { target: "npm:express", format: "json" }, - {}, - ); - expect(result.isError).toBeUndefined(); - const payload = parseText(result) as { - registry: string; - name: string; - total: number; - hasMore: boolean; - files: Array<{ path: string }>; - indexedVersion?: string; - resolution?: { resolvedRef?: string }; - }; - expect(payload.registry).toBe("npm"); - expect(payload.name).toBe("express"); - expect(payload.total).toBe(2); - expect(payload.hasMore).toBe(false); - expect(payload.files[0]?.path).toBe("src/index.js"); - expect(payload.indexedVersion).toBe("v5.2.1"); - expect(payload.resolution?.resolvedRef).toBe("v5.2.1"); - }); - - it("emits repo-URL addressing envelope", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "https://github.com/expressjs/express@main", - format: "json", - }, - {}, - ); - const payload = parseText(result) as { - registry?: string; - name?: string; - repoUrl?: string; - gitRef?: string; - }; - expect(payload.registry).toBeUndefined(); - expect(payload.name).toBeUndefined(); - expect(payload.repoUrl).toBe("https://github.com/expressjs/express"); - expect(payload.gitRef).toBe("main"); - }); - - it("emits filter.pathPrefix when caller set one", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "npm:express", - path_prefix: "src/", - format: "json", - }, - {}, - ); - const payload = parseText(result) as { - filter?: { pathPrefix?: string }; - }; - expect(payload.filter?.pathPrefix).toBe("src/"); - }); - - it("echoes advanced filters when caller set them", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "npm:express", - path: "README.md", - globs: ["test/**/*.js"], - extensions: ["js"], - file_types: ["source"], - languages: ["JavaScript"], - file_intent: "production", - exclude_file_intents: ["generated"], - exclude_doc_files: true, - include_hidden: true, - format: "json", - }, - {}, - ); - const payload = parseText(result) as { - filter?: { - path?: string; - globs?: string[]; - extensions?: string[]; - fileTypes?: string[]; - languages?: string[]; - fileIntent?: string; - excludeFileIntents?: string[]; - excludeDocFiles?: boolean; - includeHidden?: boolean; - }; - }; - expect(payload.filter).toEqual({ - path: "README.md", - globs: ["test/**/*.js"], - extensions: ["js"], - fileTypes: ["source"], - languages: ["JavaScript"], - fileIntent: "production", - excludeFileIntents: ["generated"], - excludeDocFiles: true, - includeHidden: true, - }); - }); - - it("omits filter when caller only used defaults", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { target: "npm:express", format: "json" }, - {}, - ); - const payload = parseText(result) as { filter?: unknown }; - expect(payload.filter).toBeUndefined(); - }); -}); - -describe("createListFilesTool — validation errors", () => { - it("returns INVALID_ARGUMENT for a malformed compact target", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "npm:", - }, - {}, - ); - expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string; error: string }; - expect(payload.code).toBe("INVALID_ARGUMENT"); - }); - - it("returns INVALID_ARGUMENT for out-of-range limit via envelope (not raw Zod)", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { target: "npm:express", limit: 1001 }, - {}, - ); - expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string }; - expect(payload.code).toBe("INVALID_ARGUMENT"); - }); - - it("returns INVALID_ARGUMENT for an incomplete compact target", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler({ target: "github:" }, {}); - expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string }; - expect(payload.code).toBe("INVALID_ARGUMENT"); - }); - - it("allows repo targets without git refs for default-branch intent", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { target: "https://github.com/expressjs/express" }, - {}, - ); - expect(result.isError).toBeUndefined(); - }); - - it("treats empty optional selectors as omitted", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "npm:express", - path: "", - path_prefix: "", - file_intent: "", - format: "json", - }, - {}, - ); - expect(result.isError).toBeUndefined(); - const payload = parseText(result) as { filter?: unknown }; - expect(payload.filter).toBeUndefined(); - }); -}); - -describe("createListFilesTool — service errors", () => { - it("classifies CodeNavigationIndexingError as INDEXING with retryable + details", async () => { - const service = createMockCodeNavigationService({ - listFiles: mock(() => - Promise.reject( - new CodeNavigationIndexingError( - "Target is indexing.", - "ref_abc", - [{ version: "4.21.0", ref: "v4.21.0" }], - [{ ref: "main" }], - ), - ), - ), - }); - const tool = createListFilesTool(service); - const result = await tool.handler({ target: "npm:express" }, {}); - expect(result.isError).toBe(true); - const payload = parseText(result) as { - code: string; - retryable: boolean; - details?: { - indexingRef?: string; - availableVersions?: unknown; - availableRefs?: unknown; - }; - }; - expect(payload.code).toBe("INDEXING"); - expect(payload.retryable).toBe(true); - expect(payload.details?.indexingRef).toBe("ref_abc"); - expect(payload.details?.availableVersions).toBeTruthy(); - expect(payload.details?.availableRefs).toBeTruthy(); - }); - - it("classifies CodeNavigationTargetNotFoundError as NOT_FOUND", async () => { - const service = createMockCodeNavigationService({ - listFiles: mock(() => - Promise.reject( - new CodeNavigationTargetNotFoundError("Package not found"), - ), - ), - }); - const tool = createListFilesTool(service); - const result = await tool.handler({ target: "npm:ghost" }, {}); - expect(result.isError).toBe(true); - const payload = parseText(result) as { code: string }; - expect(payload.code).toBe("NOT_FOUND"); - }); -}); - -describe("createListFilesTool — text format", () => { - it("defaults to text output when format is omitted", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler({ target: "npm:express" }, {}); - expect(result.isError).toBeUndefined(); - const text = result.content[0]?.text ?? ""; - expect(text).toContain("code_files | 2 paths"); - // Confirm text payload is not valid JSON (proves text default). - expect(() => JSON.parse(text)).toThrow(); - }); - - it("returns line-oriented text when format=text", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "npm:express", - format: "text", - }, - {}, - ); - expect(result.isError).toBeUndefined(); - const text = result.content[0]?.text ?? ""; - expect(text.split("\n")[0]).toBe("code_files | 2 paths | npm:express"); - expect(text).toContain("src/index.js"); - // Confirm the text payload is not valid JSON. - expect(() => JSON.parse(text)).toThrow(); - }); - - it("accepts explicit format=text", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "npm:express", - format: "text", - }, - {}, - ); - const text = result.content[0]?.text ?? ""; - expect(text).toContain("code_files | 2 paths"); - }); - - it("keeps JSON envelope when format=json (explicit)", async () => { - const tool = createListFilesTool(createMockCodeNavigationService()); - const result = await tool.handler( - { - target: "npm:express", - format: "json", - }, - {}, - ); - const payload = parseText(result) as { registry: string; total: number }; - expect(payload.registry).toBe("npm"); - expect(payload.total).toBe(2); - }); -}); diff --git a/packages/mcp/src/tools/list-files.ts b/packages/mcp/src/tools/list-files.ts deleted file mode 100644 index d4ae42bf..00000000 --- a/packages/mcp/src/tools/list-files.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { CodeNavigationService } from "@githits/core-internal"; -import { toPkgseerRegistryLowercase } from "@githits/core-internal"; -import { z } from "zod"; -import { knownFileIntentList } from "../shared/code-navigation.js"; -import { - DEFAULT_WAIT_TIMEOUT_MS, - MAX_WAIT_TIMEOUT_MS, -} from "../shared/code-navigation-defaults.js"; -import { mapCodeNavigationError } from "../shared/code-navigation-error-map.js"; -import { buildListFilesParams } from "../shared/list-files-request.js"; -import { buildListFilesSuccessPayload } from "../shared/list-files-response.js"; -import { renderListFilesText } from "../shared/list-files-text.js"; -import { - type CodeTargetArg, - codeTargetSchema, - resolveCodeTarget, -} from "./code-navigation-shared.js"; -import { mcpMappedErrorResult, throwIfCallerCancellation } from "./shared.js"; -import { - OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, - type ToolDefinition, - textResult, - type ZodRawShape, -} from "./types.js"; - -export interface ListFilesArgs { - target: CodeTargetArg; - path?: string; - path_prefix?: string; - globs?: string[]; - extensions?: string[]; - file_types?: string[]; - languages?: string[]; - file_intent?: string; - file_intents?: string[]; - exclude_file_intents?: string[]; - exclude_doc_files?: boolean; - exclude_test_files?: boolean; - include_hidden?: boolean; - limit?: number; - wait_timeout_ms?: number; - format?: "text" | "json"; -} - -const schema: ZodRawShape = { - target: codeTargetSchema, - path: z - .string() - .optional() - .describe( - "Exact target-relative file path to include. When combined with `path_prefix` or `globs`, files matching any selector are returned.", - ), - path_prefix: z - .string() - .optional() - .describe( - "Literal directory prefix to filter by (e.g. `src/` or `lib/parser`). NOT a glob. OR-ed with `path` and `globs` when combined.", - ), - globs: z - .array(z.string()) - .optional() - .describe( - "Repeatable glob selectors with real glob semantics (e.g. `src/**/*.ts`). OR-ed with `path` and `path_prefix`.", - ), - extensions: z - .array(z.string()) - .optional() - .describe("File extensions to include, without a leading dot."), - file_types: z - .array(z.string()) - .optional() - .describe( - "File type filters to include, matching aigrep file_type values such as `source` or `doc`.", - ), - languages: z - .array(z.string()) - .optional() - .describe("Language filters to include, matching aigrep language names."), - file_intent: z - .string() - .optional() - .describe( - `Single inclusive file-intent filter. Cannot be combined with \`file_intents\`. Valid values: ${knownFileIntentList().join(", ")}.`, - ), - file_intents: z - .array(z.string()) - .optional() - .describe( - `Inclusive file-intent filters. Cannot be combined with \`file_intent\`. Valid values: ${knownFileIntentList().join(", ")}.`, - ), - exclude_file_intents: z - .array(z.string()) - .optional() - .describe( - `Exclude these file intents after inclusive intent filtering. Valid values: ${knownFileIntentList().join(", ")}.`, - ), - exclude_doc_files: z.boolean().optional(), - exclude_test_files: z.boolean().optional(), - include_hidden: z.boolean().optional(), - limit: z - .number() - .optional() - .describe( - "Max entries to return (1–1000, default 200). Out-of-range values return an `INVALID_ARGUMENT` envelope.", - ), - wait_timeout_ms: z - .number() - .optional() - .describe( - `Time to wait for results in ms. Default ${DEFAULT_WAIT_TIMEOUT_MS}, max ${MAX_WAIT_TIMEOUT_MS}.`, - ), - format: z - .enum(["text", "json"]) - .default("text") - .describe( - "Omit `format` to use token-efficient text when the model reads the result or chooses follow-up tools. Set `json` only when code consumes the raw response instead of the model, or a required field is absent from text.", - ), -}; - -const DESCRIPTION = - "List indexed files and paths in a public repo or package. Discover paths before `read` " + - "when you don't yet know the path, or when it returns " + - "`FILE_NOT_FOUND`, `FILE_PATH_EXCLUDED`, or " + - "`SOURCE_FILE_INVENTORY_UNKNOWN`. Pass one compact `target`; use returned paths " + - "for `read.path` or to scope `code_grep`. Narrow directory enumeration with " + - "`path_prefix` (e.g. `lib/`)."; - -export function createListFilesTool( - service: CodeNavigationService, -): ToolDefinition { - return { - name: "code_files", - description: DESCRIPTION, - schema, - annotations: OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, - handler: async (args, context) => { - const target = resolveCodeTarget(args.target); - if ("content" in target) return target; - - try { - const build = buildListFilesParams({ - target, - path: args.path, - pathPrefix: args.path_prefix, - globs: args.globs, - extensions: args.extensions, - fileTypes: args.file_types, - languages: args.languages, - fileIntent: args.file_intent, - fileIntents: args.file_intents, - excludeFileIntents: args.exclude_file_intents, - excludeDocFiles: args.exclude_doc_files, - excludeTestFiles: args.exclude_test_files, - includeHidden: args.include_hidden, - limit: args.limit, - waitTimeoutMs: args.wait_timeout_ms, - }); - const result = await service.listFiles(build.params); - const payload = buildListFilesSuccessPayload(result, { - registry: target.registry - ? toPkgseerRegistryLowercase(target.registry) - : undefined, - name: target.packageName, - repoUrl: target.repoUrl, - gitRef: target.gitRef, - path: build.filterEcho.path, - pathPrefix: build.filterEcho.pathPrefix, - globs: build.filterEcho.globs, - extensions: build.filterEcho.extensions, - fileTypes: build.filterEcho.fileTypes, - languages: build.filterEcho.languages, - fileIntent: build.filterEcho.fileIntent, - fileIntents: build.filterEcho.fileIntents, - excludeFileIntents: build.filterEcho.excludeFileIntents, - excludeDocFiles: build.filterEcho.excludeDocFiles, - excludeTestFiles: build.filterEcho.excludeTestFiles, - includeHidden: build.filterEcho.includeHidden, - limit: build.filterEcho.limit, - explicit: build.explicit, - }); - if (isTextFormat(args.format)) { - return textResult(renderListFilesText(payload)); - } - return textResult(JSON.stringify(payload)); - } catch (error) { - throwIfCallerCancellation(error, context?.signal); - const mapped = mapCodeNavigationError(error); - return mcpMappedErrorResult(mapped, context); - } - }, - }; -} - -/** - * Default response format is text; programmatic callers opt into - * JSON explicitly via `format: "json"`. - */ -function isTextFormat(format: ListFilesArgs["format"]): boolean { - return format === undefined || format === "text"; -} diff --git a/packages/mcp/src/tools/list-package-docs.test.ts b/packages/mcp/src/tools/list-package-docs.test.ts deleted file mode 100644 index b456ccb1..00000000 --- a/packages/mcp/src/tools/list-package-docs.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { describe, expect, it, mock } from "bun:test"; -import { - type PackageDocsList, - PackageIntelligenceTargetNotFoundError, -} from "@githits/core-internal"; -import { createMockPackageIntelligenceService } from "../services/test-helpers.js"; -import { createListPackageDocsTool } from "./list-package-docs.js"; - -function parseText(result: { content: Array<{ text: string }> }): unknown { - return JSON.parse(result.content[0]?.text ?? ""); -} - -describe("createListPackageDocsTool", () => { - it("registers the correct tool metadata", () => { - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService(), - ); - expect(tool.name).toBe("docs_list"); - expect(tool.annotations).toEqual({ - readOnlyHint: true, - openWorldHint: true, - destructiveHint: false, - }); - expect(Object.keys(tool.schema)).toEqual([ - "target", - "limit", - "after", - "format", - ]); - expect(tool.schema.target?.description).toContain("Go accepts versions"); - expect(tool.description).toContain("`docsReadTarget`"); - expect(tool.description).toContain("mutable current content"); - expect(tool.description).toContain("snapshot-addressed"); - }); - - it("calls service.listPackageDocs with normalised params", async () => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - await tool.handler({ target: "npm:express@5.2.1", limit: 3 }, {}); - - expect(listPackageDocs).toHaveBeenCalledWith({ - registry: "NPM", - packageName: "express", - version: "5.2.1", - limit: 3, - }); - }); - - it("normalizes a trimmed uppercase npm scoped pin with pagination", async () => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - await tool.handler( - { target: " NPM:@types/node@22.0.0 ", limit: 3, after: " cursor " }, - {}, - ); - - expect(listPackageDocs).toHaveBeenCalledWith({ - registry: "NPM", - packageName: "@types/node", - version: "22.0.0", - limit: 3, - after: "cursor", - }); - }); - - it("normalizes an unpinned npm target without a version", async () => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - await tool.handler({ target: "npm:express" }, {}); - - expect(listPackageDocs).toHaveBeenCalledWith({ - registry: "NPM", - packageName: "express", - }); - }); - - it.each([ - "go:github.com/gin-gonic/gin@1.2.3", - "go:github.com/gin-gonic/gin@v1.2.3", - ])("normalizes Go target %s to a v-prefixed version", async (target) => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - await tool.handler({ target }, {}); - - expect(listPackageDocs).toHaveBeenCalledWith({ - registry: "GO", - packageName: "github.com/gin-gonic/gin", - version: "v1.2.3", - }); - }); - - it("normalizes a Swift GitHub target and package name", async () => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - await tool.handler( - { target: "swift:github.com/Apple/Swift-Argument-Parser@v1.5.0" }, - {}, - ); - - expect(listPackageDocs).toHaveBeenCalledWith({ - registry: "SWIFT", - packageName: "github.com/apple/swift-argument-parser", - version: "v1.5.0", - }); - }); - - it("normalizes a Maven coordinate", async () => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - await tool.handler( - { target: "maven:org.apache.commons:commons-lang3@3.17.0" }, - {}, - ); - - expect(listPackageDocs).toHaveBeenCalledWith({ - registry: "MAVEN", - packageName: "org.apache.commons:commons-lang3", - version: "3.17.0", - }); - }); - - it("returns JSON-stringified lean envelope when format=json", async () => { - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService(), - ); - const result = await tool.handler( - { target: "npm:express", format: "json" }, - {}, - ); - const payload = parseText(result) as Record; - expect(payload.name).toBe("express"); - expect(Array.isArray(payload.pages)).toBe(true); - }); - - it("renders active empty results as in progress and preserves JSON state", async () => { - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ - listPackageDocs: mock(() => - Promise.resolve({ - registry: "NPM", - packageName: "express", - version: "5.2.1", - codeIndexState: "INDEXING", - pages: [], - pageInfo: { hasNextPage: false, totalCount: 0 }, - }), - ), - }), - ); - - const textResult = await tool.handler({ target: "npm:express" }, {}); - expect(textResult.content[0]?.text).toContain( - "indexing is still in progress", - ); - expect(textResult.content[0]?.text).not.toContain( - "No documentation pages found.", - ); - - const jsonResult = await tool.handler( - { target: "npm:express", format: "json" }, - {}, - ); - expect(parseText(jsonResult)).toMatchObject({ - codeIndexState: "INDEXING", - pages: [], - }); - }); - - it("defaults to compact text output", async () => { - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService(), - ); - const result = await tool.handler({ target: "npm:express" }, {}); - const text = result.content[0]?.text ?? ""; - expect(text).toContain("docs_list | npm:express"); - expect(text).toContain("read target="); - expect(() => JSON.parse(text)).toThrow(); - }); - - it("prefers docsReadTarget in text follow-ups and retains all JSON locators", async () => { - const docsReadTarget = - "https://docs.example.test/guide with spaces;$(echo nope)?q='quoted'&x=*"; - const listPackageDocs = mock(() => - Promise.resolve({ - registry: "npm", - packageName: "example", - pages: [ - { - id: "legacy-crawled-id", - docsReadTarget, - title: "Publisher guide", - sourceKind: "CRAWLED" as const, - sourceUrl: "https://docs.example.test/guide with spaces", - }, - ], - pageInfo: { hasNextPage: false }, - }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - const textResult = await tool.handler({ target: "npm:example" }, {}); - expect(textResult.content[0]?.text).toContain( - `read target=${JSON.stringify(docsReadTarget)}`, - ); - expect(textResult.content[0]?.text).not.toContain( - 'read target="legacy-crawled-id"', - ); - - const jsonResult = await tool.handler( - { target: "npm:example", format: "json" }, - {}, - ); - const payload = parseText(jsonResult) as { - pages: Array<{ - docsReadTarget: string; - pageId: string; - sourceKind: string; - sourceUrl: string; - title: string; - }>; - }; - expect(payload.pages[0]).toEqual({ - docsReadTarget, - pageId: "legacy-crawled-id", - sourceKind: "crawled", - sourceUrl: "https://docs.example.test/guide with spaces", - title: "Publisher guide", - }); - }); - - it("uses the canonical docs target despite conflicting repo provenance", async () => { - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ - listPackageDocs: mock(() => - Promise.resolve({ - registry: "npm", - packageName: "ms", - version: "2.1.3", - pages: [ - { - id: "github:vercel/ms@sha/readme.md", - docsReadTarget: "github:vercel/ms@sha/readme.md", - title: "readme.md", - sourceKind: "REPOSITORY", - sourceUrl: "https://github.com/vercel/ms/blob/sha/readme.md", - repoUrl: "https://github.com/vercel/ms", - gitRef: "served-sha", - requestedRef: "main", - filePath: "readme.md", - }, - ], - pageInfo: { hasNextPage: false }, - } satisfies PackageDocsList), - ), - }), - ); - - const result = await tool.handler({ target: "npm:ms" }, {}); - const text = result.content[0]?.text ?? ""; - expect(text).toContain('read target="github:vercel/ms@sha/readme.md"'); - expect(text.match(/read target=/g)).toHaveLength(1); - expect(text).not.toContain('read target="github:vercel/ms@served-sha"'); - expect(text).not.toContain("#main"); - }); - - it("omits nullish lastUpdatedAt values from the lean envelope", async () => { - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ - listPackageDocs: mock(() => - Promise.resolve({ - registry: "npm", - packageName: "ms", - version: "2.1.3", - pages: [ - { - id: "github:vercel/ms@sha/readme.md", - docsReadTarget: "github:vercel/ms@sha/readme.md", - title: "readme.md", - sourceKind: "REPOSITORY", - sourceUrl: "https://github.com/vercel/ms/blob/sha/readme.md", - repoUrl: "https://github.com/vercel/ms", - gitRef: "sha", - filePath: "readme.md", - }, - ], - pageInfo: { hasNextPage: false }, - } satisfies PackageDocsList), - ), - }), - ); - - const result = await tool.handler({ target: "npm:ms", format: "json" }, {}); - const payload = parseText(result) as { - pages: Array<{ lastUpdatedAt?: string }>; - }; - expect(payload.pages[0]?.lastUpdatedAt).toBeUndefined(); - }); - - it("returns INVALID_ARGUMENT for unknown registry", async () => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - const result = await tool.handler({ target: "cargo:serde" }, {}); - const payload = parseText(result) as { code: string; retryable: boolean }; - expect(result.isError).toBe(true); - expect(payload).toMatchObject({ - code: "INVALID_ARGUMENT", - retryable: false, - }); - expect(listPackageDocs).not.toHaveBeenCalled(); - }); - - it.each([ - "", - " ", - "express", - "npm:", - "npm:express@", - "madeup:express", - "github:expressjs/express", - "site:expressjs.com", - ])( - "rejects invalid compact target %j without calling service", - async (target) => { - const listPackageDocs = mock(() => - Promise.resolve({ pages: [], pageInfo: { hasNextPage: false } }), - ); - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ listPackageDocs }), - ); - - const result = await tool.handler({ target }, {}); - - expect(result.isError).toBe(true); - expect(parseText(result)).toMatchObject({ - code: "INVALID_ARGUMENT", - retryable: false, - }); - expect(listPackageDocs).not.toHaveBeenCalled(); - }, - ); - - it("classifies target-not-found errors as NOT_FOUND", async () => { - const tool = createListPackageDocsTool( - createMockPackageIntelligenceService({ - listPackageDocs: mock(() => - Promise.reject( - new PackageIntelligenceTargetNotFoundError("Package not found"), - ), - ), - }), - ); - const result = await tool.handler({ target: "npm:ghost" }, {}); - const payload = parseText(result) as { code: string }; - expect(result.isError).toBe(true); - expect(payload.code).toBe("NOT_FOUND"); - }); -}); diff --git a/packages/mcp/src/tools/list-package-docs.ts b/packages/mcp/src/tools/list-package-docs.ts deleted file mode 100644 index da6024ba..00000000 --- a/packages/mcp/src/tools/list-package-docs.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { PackageIntelligenceService } from "@githits/core-internal"; -import { PKGSEER_REGISTRY_LIST } from "@githits/core-internal"; -import { z } from "zod"; -import { buildListPackageDocsParams } from "../shared/list-package-docs-request.js"; -import { buildListPackageDocsSuccessPayload } from "../shared/list-package-docs-response.js"; -import { renderListPackageDocsText } from "../shared/list-package-docs-text.js"; -import { mapPackageIntelligenceError } from "../shared/package-intelligence-error-map.js"; -import { parsePackageSpec } from "../shared/package-spec.js"; -import { DOCS_GUARDRAIL } from "./guardrails.js"; -import { mcpMappedErrorResult, throwIfCallerCancellation } from "./shared.js"; -import { - OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, - type ToolDefinition, - textResult, - type ZodRawShape, -} from "./types.js"; - -export interface ListPackageDocsArgs { - target: string; - limit?: number; - after?: string; - format?: "text" | "json"; -} - -const schema: ZodRawShape = { - target: z - .string() - .describe( - `Package registry:name[@version], for example npm:express@5.2.1; omit the version for latest. Go accepts versions with or without v. Registries: ${PKGSEER_REGISTRY_LIST}.`, - ), - limit: z - .number() - .optional() - .describe("Max pages to return (1-500, default 100)."), - after: z - .string() - .optional() - .describe("Pagination cursor from a prior response."), - format: z - .enum(["text", "json"]) - .default("text") - .describe( - "Omit `format` to use token-efficient text when the model reads the result or chooses follow-up tools. Set `json` only when code consumes the raw response instead of the model, or a required field is absent from text.", - ), -}; - -const DESCRIPTION = - "List package documentation targets for follow-up reads. " + - "Package targets only, not standalone `site:` targets. Pass an entry's preferred " + - "`docsReadTarget` to `read.target`; historical `pageId` values remain readable. " + - "Hosted HTTP(S) targets address mutable current content; repo targets are snapshot-addressed. " + - "Repo-backed entries supply exact `repoUrl` / `gitRef` / `filePath` for source reads." + - `\n\n${DOCS_GUARDRAIL}`; - -export function createListPackageDocsTool( - service: PackageIntelligenceService, -): ToolDefinition { - return { - name: "docs_list", - description: DESCRIPTION, - schema, - annotations: OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, - handler: async (args, context) => { - try { - const target = parsePackageSpec(args.target.trim()); - const build = buildListPackageDocsParams({ - registry: target.registry, - packageName: target.name, - version: target.version, - limit: args.limit, - after: args.after, - }); - const result = await service.listPackageDocs(build.params); - const payload = buildListPackageDocsSuccessPayload(result, { - limitExplicit: build.limitExplicit, - afterExplicit: build.afterExplicit, - limit: build.params.limit, - after: build.params.after, - }); - if (isTextFormat(args.format)) { - return textResult(renderListPackageDocsText(payload)); - } - return textResult(JSON.stringify(payload)); - } catch (error) { - throwIfCallerCancellation(error, context?.signal); - const mapped = mapPackageIntelligenceError(error); - return mcpMappedErrorResult(mapped, context); - } - }, - }; -} - -function isTextFormat(format: ListPackageDocsArgs["format"]): boolean { - return format === undefined || format === "text"; -} diff --git a/packages/mcp/src/tools/list.test.ts b/packages/mcp/src/tools/list.test.ts new file mode 100644 index 00000000..88a8435d --- /dev/null +++ b/packages/mcp/src/tools/list.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it, mock } from "bun:test"; +import type { + ListParams, + ListResult, + ListService, +} from "@githits/core-internal"; +import { ListGraphQLError } from "@githits/core-internal"; +import { z } from "zod"; +import { projectListResult } from "../shared/list-response.js"; +import { formatListText } from "../shared/list-text.js"; +import { createListTool } from "./list.js"; + +const FIRST_SENTENCE = + "List files and documentation paths in a known package, repository, or site."; + +function listResult(overrides: Partial = {}): ListResult { + return { + inventoryKind: "SOURCE", + requestedTarget: "npm:express@5.2.1", + canonicalTarget: "npm:express@5.2.1", + entries: [], + hasMore: false, + nextCursor: null, + indexedVersion: null, + codeIndexState: null, + indexingStatus: null, + indexingRef: null, + inventoryState: null, + crawlStatus: null, + coverageState: null, + coverageReason: null, + preparation: null, + ...overrides, + }; +} + +function createService( + list: (params: ListParams) => Promise, +): ListService { + return { list: mock(list) }; +} + +function errorPayload(result: { content: Array<{ text: string }> }) { + return JSON.parse(result.content[0]?.text ?? "{}"); +} + +describe("createListTool", () => { + it("advertises listing intent and the later legacy-name compatibility sentence", () => { + const tool = createListTool(createService(async () => listResult())); + const firstSentence = tool.description.slice( + 0, + tool.description.indexOf(".") + 1, + ); + const first80 = tool.description.slice(0, 80); + + expect(tool.name).toBe("list"); + expect(tool.annotations).toEqual({ + readOnlyHint: true, + openWorldHint: true, + destructiveHint: false, + }); + expect(firstSentence).toBe(FIRST_SENTENCE); + expect(firstSentence.length).toBeLessThanOrEqual(79); + expect(first80).toStartWith(`${FIRST_SENTENCE} Use`); + expect(first80).not.toContain("code_files"); + expect(first80).not.toContain("docs_list"); + expect(tool.description).toContain("Replaces code_files and docs_list."); + expect(tool.description).toContain("find an exact path before `read`"); + expect(tool.description).toContain("use `search` for topics"); + expect(tool.description).toContain("one package-owned tree"); + expect(tool.description).toContain("whole snapshot"); + expect(tool.description).toContain("Both include source and documentation"); + expect(tool.description).toContain("explicit `site:` target"); + expect(tool.description).toContain("target-relative literals or globs"); + expect(tool.description).toContain( + "Directories show immediate children unless `recursive`", + ); + expect(tool.description).toContain( + "glob depth is independent of recursion", + ); + expect(tool.description).toContain("read and continuation guidance"); + expect(tool.description).toContain( + "use JSON only when code consumes the raw response programmatically", + ); + expect(tool.schema.format!.description).toContain( + "parsing or filtering it programmatically", + ); + }); + + it("defines exactly ten fields with defaults, bounds, and empty values allowed", () => { + const tool = createListTool(createService(async () => listResult())); + const schema = z.object(tool.schema); + const parsed = schema.parse({ + target: "npm:express@5.2.1", + paths: [], + recursive: false, + file_types: [], + languages: [], + intents: [], + after: "", + }); + + expect(Object.keys(tool.schema)).toEqual([ + "target", + "paths", + "recursive", + "file_types", + "languages", + "intents", + "limit", + "after", + "wait_timeout_ms", + "format", + ]); + expect(tool.schema.target!.description).toContain("site:expressjs.com"); + expect(tool.schema.paths!.description).toContain( + "Target-relative literal paths and globs", + ); + expect(tool.schema.recursive!.description).toContain( + "independent of recursion", + ); + expect(tool.schema.file_types!.description).toContain( + "Source inventories only", + ); + expect(tool.schema.languages!.description).toContain( + "Source inventories only", + ); + expect(tool.schema.intents!.description).toContain( + "Source inventories only", + ); + expect(tool.schema.after!.description).toContain("same target, paths"); + expect(tool.schema.format!.description).toContain("token-efficient text"); + expect(parsed.format).toBe("text"); + expect(parsed.paths).toEqual([]); + expect(parsed.recursive).toBe(false); + expect(parsed.after).toBe(""); + expect( + schema.safeParse({ target: "site:docs.example", file_types: ["doc"] }) + .success, + ).toBe(true); + expect( + schema.safeParse({ + target: "npm:x", + paths: Array.from({ length: 1000 }, () => "a"), + }).success, + ).toBe(true); + expect( + schema.safeParse({ + target: "npm:x", + paths: Array.from({ length: 1001 }, () => "a"), + }).success, + ).toBe(false); + expect( + schema.safeParse({ + target: "npm:x", + file_types: Array.from({ length: 65 }, () => "source"), + }).success, + ).toBe(false); + expect( + schema.safeParse({ + target: "npm:x", + languages: Array.from({ length: 65 }, () => "TypeScript"), + }).success, + ).toBe(false); + expect( + schema.safeParse({ target: "npm:x", intents: ["PRODUCTION", "VENDOR"] }) + .success, + ).toBe(true); + expect( + schema.safeParse({ + target: "npm:x", + intents: Array.from({ length: 64 }, () => "TEST"), + }).success, + ).toBe(true); + expect( + schema.safeParse({ + target: "npm:x", + intents: Array.from({ length: 65 }, () => "TEST"), + }).success, + ).toBe(false); + expect( + schema.safeParse({ target: "npm:x", intents: ["integration-test"] }) + .success, + ).toBe(false); + expect( + schema.safeParse({ target: "npm:x", limit: 1, wait_timeout_ms: 0 }) + .success, + ).toBe(true); + expect( + schema.safeParse({ + target: "npm:x", + limit: 500, + wait_timeout_ms: 300_000, + }).success, + ).toBe(true); + expect(schema.safeParse({ target: "npm:x", limit: 0 }).success).toBe(false); + expect(schema.safeParse({ target: "npm:x", limit: 501 }).success).toBe( + false, + ); + expect( + schema.safeParse({ target: "npm:x", wait_timeout_ms: -1 }).success, + ).toBe(false); + expect( + schema.safeParse({ target: "npm:x", wait_timeout_ms: 300_001 }).success, + ).toBe(false); + }); + + it("normalizes empty arrays and cursor while preserving explicit recursive false", async () => { + const response = listResult(); + const list = mock(async (_params: ListParams) => response); + const tool = createListTool({ list }); + + await tool.handler( + { + target: "npm:express@5.2.1", + paths: [], + recursive: false, + file_types: [], + languages: [], + intents: [], + after: "", + }, + {}, + ); + + expect(list).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledWith({ + target: "npm:express@5.2.1", + recursive: false, + includeDetailedFields: false, + includeReadActions: false, + }); + }); + + it("uses compact source projection and the shared path-only formatter by default", async () => { + const response = listResult({ + entries: [ + { kind: "DIRECTORY", path: "src" }, + { kind: "FILE", path: "README.md" }, + ], + }); + const list = mock(async (_params: ListParams) => response); + const tool = createListTool({ list }); + + const result = await tool.handler({ target: "npm:express@5.2.1" }, {}); + + expect(list).toHaveBeenCalledTimes(1); + expect(list).toHaveBeenCalledWith({ + target: "npm:express@5.2.1", + includeDetailedFields: false, + includeReadActions: false, + }); + expect(result.content[0]?.text).toBe( + formatListText(projectListResult(response), { + useColors: false, + syntax: "mcp", + }), + ); + expect(result.content[0]?.text).toBe( + '# source npm:express@5.2.1 | follow up with "read npm:express@5.2.1 $path"\nsrc/\nREADME.md', + ); + }); + + it("requests site read actions and renders the shared relative site paths", async () => { + const response = listResult({ + inventoryKind: "SITE", + requestedTarget: "site:expressjs.com", + canonicalTarget: "site:expressjs.com", + entries: [ + { + kind: "PAGE", + path: "expressjs.com/en/resources/", + read: { target: "site:expressjs.com", path: "en/resources/" }, + }, + { kind: "DIRECTORY", path: "en/resources/guide/" }, + ], + }); + const list = mock(async (_params: ListParams) => response); + const tool = createListTool({ list }); + + const result = await tool.handler({ target: "site:expressjs.com" }, {}); + + expect(list).toHaveBeenCalledWith({ + target: "site:expressjs.com", + includeDetailedFields: false, + includeReadActions: true, + }); + expect(result.content[0]?.text).toBe( + formatListText(projectListResult(response), { + useColors: false, + syntax: "mcp", + }), + ); + expect(result.content[0]?.text).toBe( + '# source site:expressjs.com | follow up with "read site:expressjs.com $path"\nen/resources/\nen/resources/guide/', + ); + }); + + it("returns detailed JSON with exact actions and the continuation cursor", async () => { + const response = listResult({ + hasMore: true, + nextCursor: "opaque-cursor", + entries: [ + { + kind: "FILE", + path: "src/index.ts", + read: { + target: "github:expressjs/express@abc123", + path: "src/index.ts", + }, + }, + { + kind: "DIRECTORY", + path: "src/", + browse: { + target: "github:expressjs/express@abc123", + paths: ["src/"], + }, + }, + ], + }); + const list = mock(async (_params: ListParams) => response); + const tool = createListTool({ list }); + + const result = await tool.handler( + { target: "github:expressjs/express", format: "json" }, + {}, + ); + + expect(list).toHaveBeenCalledWith({ + target: "github:expressjs/express", + includeDetailedFields: true, + includeReadActions: true, + }); + expect(JSON.parse(result.content[0]?.text ?? "{}")).toEqual( + projectListResult(response), + ); + expect(JSON.parse(result.content[0]?.text ?? "{}")).toMatchObject({ + hasMore: true, + nextCursor: "opaque-cursor", + entries: [ + { + read: { + target: "github:expressjs/express@abc123", + path: "src/index.ts", + }, + }, + { + browse: { + target: "github:expressjs/express@abc123", + paths: ["src/"], + }, + }, + ], + }); + }); + + it("maps invalid input, adds cursor reset guidance, and propagates caller cancellation", async () => { + const unusedList = mock(async (_params: ListParams) => listResult()); + const invalidTool = createListTool({ list: unusedList }); + const invalid = await invalidTool.handler( + { target: "npm:express", paths: [" "], after: "opaque-cursor" }, + {}, + ); + + expect(invalid.isError).toBe(true); + expect(errorPayload(invalid)).toMatchObject({ code: "INVALID_ARGUMENT" }); + expect(errorPayload(invalid).error).not.toContain( + "Retry once without `after`", + ); + expect(unusedList).not.toHaveBeenCalled(); + + const cursorError = createListTool({ + list: mock(async () => { + throw new ListGraphQLError("Cursor expired.", "VALIDATION_ERROR"); + }), + }); + const cursorResult = await cursorError.handler( + { target: "npm:express", after: "opaque-cursor" }, + {}, + ); + expect(errorPayload(cursorResult)).toMatchObject({ + code: "INVALID_ARGUMENT", + error: expect.stringContaining("Retry once without `after`"), + }); + + const cancellation = new Error("caller cancelled"); + cancellation.name = "AbortError"; + const controller = new AbortController(); + controller.abort(cancellation); + const cancelledTool = createListTool({ + list: mock(async () => { + throw cancellation; + }), + }); + await expect( + cancelledTool.handler( + { target: "npm:express" }, + { signal: controller.signal }, + ), + ).rejects.toBe(cancellation); + }); +}); diff --git a/packages/mcp/src/tools/list.ts b/packages/mcp/src/tools/list.ts new file mode 100644 index 00000000..1b9bc6f4 --- /dev/null +++ b/packages/mcp/src/tools/list.ts @@ -0,0 +1,160 @@ +import type { + ListFileIntent, + ListParams, + ListService, +} from "@githits/core-internal"; +import { z } from "zod"; +import { mapListError } from "../shared/list-error-map.js"; +import { buildListParams } from "../shared/list-request.js"; +import { projectListResult } from "../shared/list-response.js"; +import { formatListText } from "../shared/list-text.js"; +import { mcpMappedErrorResult, throwIfCallerCancellation } from "./shared.js"; +import { + OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + type ToolDefinition, + textResult, + type ZodRawShape, +} from "./types.js"; + +export interface ListArgs { + target: string; + paths?: string[]; + recursive?: boolean; + file_types?: string[]; + languages?: string[]; + intents?: ListFileIntent[]; + limit?: number; + after?: string; + wait_timeout_ms?: number; + format?: "text" | "json"; +} + +const LIST_INTENTS = [ + "PRODUCTION", + "TEST", + "BENCHMARK", + "EXAMPLE", + "GENERATED", + "FIXTURE", + "BUILD", + "VENDOR", +] as const satisfies readonly ListFileIntent[]; + +const schema: ZodRawShape = { + target: z + .string() + .describe( + "Known package such as `npm:express@5.2.1`, repository such as `github:expressjs/express`, or hosted docs site such as `site:expressjs.com`.", + ), + paths: z + .array(z.string()) + .max(1000) + .optional() + .describe( + "Target-relative literal paths and globs form a union for packages, repositories, and sites. Omit or pass `[]` to browse the root.", + ), + recursive: z + .boolean() + .optional() + .describe( + "Expand selected directories to descendant leaves. Without this, selected directories show immediate children. Glob depth is independent of recursion.", + ), + file_types: z + .array(z.string()) + .max(64) + .optional() + .describe("Source inventories only; filter by file type."), + languages: z + .array(z.string()) + .max(64) + .optional() + .describe("Source inventories only; filter by language."), + intents: z + .array(z.enum(LIST_INTENTS)) + .max(64) + .optional() + .describe( + "Source inventories only; filter by file intent: PRODUCTION, TEST, BENCHMARK, EXAMPLE, GENERATED, FIXTURE, BUILD, or VENDOR.", + ), + limit: z + .number() + .int() + .min(1) + .max(500) + .optional() + .describe("Maximum entries to return (1-500)."), + after: z + .string() + .optional() + .describe( + "Opaque `nextCursor` from a prior list response. Reuse the same target, paths, filters, recursion, and limit; empty is omitted.", + ), + wait_timeout_ms: z + .number() + .int() + .min(0) + .max(300_000) + .optional() + .describe("Maximum wait for indexing in milliseconds (0-300000)."), + format: z + .enum(["text", "json"]) + .default("text") + .describe( + "Omit `format` to use token-efficient text when the model reads the result or follows read and continuation guidance. Set `json` only when code consumes the raw response instead of the model by parsing or filtering it programmatically.", + ), +}; + +const DESCRIPTION = + "List files and documentation paths in a known package, repository, or site. " + + "Use it to browse structure or find an exact path before `read`; use `search` " + + "for topics.\n\n" + + "Replaces code_files and docs_list. Package targets cover one package-owned " + + "tree; repository targets cover the whole snapshot. Both include source and " + + "documentation. Hosted docs use a separate explicit `site:` target from docs " + + "search. `paths` are target-relative literals or globs for every target and " + + "form a union; omit them for the root. Directories show immediate children " + + "unless `recursive` expands them; glob depth is independent of recursion. Keep text for " + + "model use, including read and continuation guidance; use JSON only when code " + + "consumes the raw response programmatically."; + +export function createListTool( + service: ListService, +): ToolDefinition { + return { + name: "list", + description: DESCRIPTION, + schema, + annotations: OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + handler: async (args, context) => { + let builtParams: ListParams | undefined; + try { + builtParams = buildListParams({ + target: args.target, + paths: args.paths, + recursive: args.recursive, + fileTypes: args.file_types, + languages: args.languages, + intents: args.intents, + limit: args.limit, + after: args.after, + waitTimeoutMs: args.wait_timeout_ms, + includeDetailedFields: args.format === "json", + }); + const result = await service.list(builtParams); + const payload = projectListResult(result); + if (args.format !== "json") { + return textResult( + formatListText(payload, { useColors: false, syntax: "mcp" }), + ); + } + return textResult(JSON.stringify(payload)); + } catch (error) { + throwIfCallerCancellation(error, context?.signal); + const mapped = mapListError(error, { + hasAfter: builtParams?.after !== undefined, + }); + return mcpMappedErrorResult(mapped, context); + } + }, + }; +} diff --git a/packages/mcp/src/tools/read-file.test.ts b/packages/mcp/src/tools/read-file.test.ts index 53cb2974..70202183 100644 --- a/packages/mcp/src/tools/read-file.test.ts +++ b/packages/mcp/src/tools/read-file.test.ts @@ -293,7 +293,9 @@ describe("createCodeReadTool — validation errors", () => { const payload = parseText(result) as { code: string; error: string }; expect(payload.code).toBe("INVALID_ARGUMENT"); expect(payload.error).toContain("exact file path"); - expect(payload.error).toContain('path_prefix: "lib/"'); + expect(payload.error).toContain('`list` with `paths: ["lib/"]`'); + expect(payload.error).not.toContain("code_files"); + expect(payload.error).not.toContain("path_prefix"); expect(readFile).not.toHaveBeenCalled(); }); }); @@ -325,8 +327,9 @@ describe("createCodeReadTool — service errors", () => { }; expect(payload.code).toBe("FILE_NOT_FOUND"); expect(payload.details?.filePath).toBe("nope.js"); - expect(payload.details?.action).toContain("`code_files`"); - expect(payload.details?.action).toContain("without `path_prefix`"); + expect(payload.details?.action).toContain("Use `list` without `paths`"); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); expect(payload.details?.action).toContain("emitted `path`"); }); @@ -361,8 +364,11 @@ describe("createCodeReadTool — service errors", () => { details?: { action?: string }; }; expect(payload.details?.action).toContain(expectedGuidance); - expect(payload.details?.action).toContain("`code_files`"); - expect(payload.details?.action).toContain('path_prefix: "bench/data/"'); + expect(payload.details?.action).toContain( + '`list` with `paths: ["bench/data/"]`', + ); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); expect(payload.details?.action).toContain("`read`"); expect(payload.details?.action).not.toContain("githits code"); }, @@ -390,12 +396,12 @@ describe("createCodeReadTool — service errors", () => { const payload = parseText(result) as { details?: { action?: string }; }; - expect(payload.details?.action).toContain('path_prefix: "lib/"'); + expect(payload.details?.action).toContain('`list` with `paths: ["lib/"]`'); expect(payload.details?.action).not.toContain("./lib/"); expect(payload.details?.action).not.toContain("lib/internal/"); }); - it("points directory-looking NOT_FOUND errors at code_files path_prefix", async () => { + it("points directory-looking NOT_FOUND errors at list paths", async () => { const service = createMockCodeNavigationService({ readFile: mock(() => Promise.reject( @@ -418,7 +424,9 @@ describe("createCodeReadTool — service errors", () => { }; expect(payload.code).toBe("NOT_FOUND"); expect(payload.details?.action).toContain("reads files only"); - expect(payload.details?.action).toContain('path_prefix: "lib/"'); + expect(payload.details?.action).toContain('`list` with `paths: ["lib/"]`'); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); }); it("does not add file recovery to unrelated NOT_FOUND errors", async () => { diff --git a/packages/mcp/src/tools/read.test.ts b/packages/mcp/src/tools/read.test.ts index 0f43edb1..05c91067 100644 --- a/packages/mcp/src/tools/read.test.ts +++ b/packages/mcp/src/tools/read.test.ts @@ -84,6 +84,9 @@ describe("unified read contract", () => { "relative to the supplied site: target; do not repeat its scope", ), }); + expect(tool.schema.path?.description).toContain( + "from search, list, or code_grep", + ); expect(schema.required).toEqual(["target"]); expect(tool.annotations).toEqual({ readOnlyHint: true, diff --git a/packages/mcp/src/tools/read.ts b/packages/mcp/src/tools/read.ts index 3db77b4c..2d659308 100644 --- a/packages/mcp/src/tools/read.ts +++ b/packages/mcp/src/tools/read.ts @@ -57,7 +57,7 @@ export const readSchema: ReadSchema = { .string() .optional() .describe( - "Page path relative to the supplied site: target; do not repeat its scope. For code, use an exact package/repo-relative file path from search, code_files or code_grep. Preserve emitted paths unchanged. Omit for other documentation targets; empty means omitted.", + "Page path relative to the supplied site: target; do not repeat its scope. For code, use an exact package/repo-relative file path from search, list, or code_grep. Preserve emitted paths unchanged. Omit for other documentation targets; empty means omitted.", ), selector: z .string() @@ -98,7 +98,7 @@ export const DESCRIPTION_BASE: string = "Replaces code_read and docs_read. " + "Hosted/crawled HTTP(S) docs targets read mutable current content; repository-doc targets address snapshots. " + "A docs URL fragment needs no bounds and returns its heading with the full subtree through the next equal-or-higher heading; either bound replaces it with a page-relative range. " + - "Use emitted locators to preserve exact revisions. It does not list directories: use code_files. " + + "Use emitted locators to preserve exact revisions. It does not list directories: use list. " + "Read focused windows from search/code_grep; follow returned continuation and error actions. " + "On INDEXING retry the same target/path with wait_timeout_ms; no content is available yet."; export const DESCRIPTION: string = `${DESCRIPTION_BASE}\n\n${CODE_READ_GUARDRAIL}`; diff --git a/packages/mcp/src/tools/tool-services.ts b/packages/mcp/src/tools/tool-services.ts index 73abad0b..33c78fb3 100644 --- a/packages/mcp/src/tools/tool-services.ts +++ b/packages/mcp/src/tools/tool-services.ts @@ -1,6 +1,7 @@ import type { CodeNavigationService, GitHitsService, + ListService, PackageIntelligenceService, ReadService, } from "@githits/core-internal"; @@ -15,5 +16,6 @@ export interface McpToolServices { githitsService: GitHitsService; codeNavigationService: CodeNavigationService; packageIntelligenceService: PackageIntelligenceService; + listService: ListService; readService: ReadService; } diff --git a/scripts/agent-eval-suite.test.ts b/scripts/agent-eval-suite.test.ts index 8e3718f0..76441a3c 100644 --- a/scripts/agent-eval-suite.test.ts +++ b/scripts/agent-eval-suite.test.ts @@ -393,10 +393,10 @@ describe("agent eval suites", () => { it("loads the checked-in manifest with the exact workload inventory", () => { const manifest = loadSuiteManifest(); expect(manifest.schemaVersion).toBe(1); - expect(manifest.workloads).toHaveLength(31); + expect(manifest.workloads).toHaveLength(36); expect( manifest.workloads.filter((workload) => workload.safety === "stable"), - ).toHaveLength(25); + ).toHaveLength(30); expect( manifest.workloads.filter((workload) => workload.safety === "stateful"), ).toHaveLength(1); @@ -432,6 +432,11 @@ describe("agent eval suites", () => { "docs-search-noise", "express-router", "global-example", + "list-continuation", + "list-package-docs-site", + "list-package-repository", + "list-recursion-glob", + "list-site-read", "opencode-compaction", "package-changelog", "package-changelog-exact", diff --git a/scripts/cli-smoke.ts b/scripts/cli-smoke.ts index d396a2cb..db662a68 100644 --- a/scripts/cli-smoke.ts +++ b/scripts/cli-smoke.ts @@ -1,4 +1,7 @@ -import { buildCliDocsReadCommand } from "@githits/mcp/internal"; +import { + buildCliDocsReadCommand, + shellQuoteExact, +} from "@githits/mcp/internal"; import { isResolveDirectTargetUnwarned } from "./resolve-smoke-guidance.ts"; import { createIsolatedSmokeEnvironment, @@ -160,31 +163,38 @@ export const JSON_PARITY_FIXTURES: JsonParityFixture[] = [ }, }, { - name: "docs_list", - cliArgs: ["docs", "list", SMOKE_PACKAGE_SPEC, "--limit", "2", "--json"], - mcpTool: "docs_list", + name: "list_package", + cliArgs: [ + "list", + SMOKE_PACKAGE_SPEC, + "package.json", + "--limit", + "1", + "--json", + ], + mcpTool: "list", mcpArgs: { target: SMOKE_PACKAGE_SPEC, - limit: 2, + paths: ["package.json"], + limit: 1, format: "json", }, }, { - name: "code_files", + name: "list_site", cliArgs: [ - "code", - "files", - SMOKE_PACKAGE_SPEC, - "package.json", + "list", + "site:expressjs.com", + "en/resources/", "--limit", - "1", + "20", "--json", ], - mcpTool: "code_files", + mcpTool: "list", mcpArgs: { - target: SMOKE_PACKAGE_SPEC, - path_prefix: "package.json", - limit: 1, + target: "site:expressjs.com", + paths: ["en/resources/"], + limit: 20, format: "json", }, }, @@ -2120,7 +2130,25 @@ async function runLiveSmoke(env: Record): Promise { const firstPackageEntry = packageListJson.entries[0] as unknown; assertRecord(firstPackageEntry, "list package first entry"); - const packageListNext = assertJsonOutput( + const packageListFirstText = assertTerminalOutput( + await runCli(["list", SMOKE_PACKAGE_SPEC, "--limit", "1"]), + "list package first page text", + ); + assert( + packageListFirstText.includes( + `More results: reuse the same target, paths, and options with:\n --after ${shellQuoteExact(packageListJson.nextCursor)}`, + ), + "list package first page text missing exact continuation cursor", + ); + const firstPackagePath = packageListFirstText.split("\n")[1]; + assert( + typeof firstPackagePath === "string" && + firstPackagePath.length > 0 && + firstPackagePath === firstPackageEntry.path, + "list package first page text missing path", + ); + + const packageListNext = assertTerminalOutput( await runCli([ "list", SMOKE_PACKAGE_SPEC, @@ -2128,21 +2156,15 @@ async function runLiveSmoke(env: Record): Promise { "1", "--after", packageListJson.nextCursor, - "--json", ]), - "list package continuation", - ); - assertRecord(packageListNext, "list package continuation"); - assert( - Array.isArray(packageListNext.entries) && - packageListNext.entries.length === 1, - "list package continuation missing entry", + "list package continuation text", ); - const nextPackageEntry = packageListNext.entries[0] as unknown; - assertRecord(nextPackageEntry, "list package continuation entry"); + const nextPackagePath = packageListNext.split("\n")[1]; assert( - nextPackageEntry.path !== firstPackageEntry.path, - "list package continuation repeated the first entry", + typeof nextPackagePath === "string" && + nextPackagePath.length > 0 && + nextPackagePath !== firstPackagePath, + "list package text continuation repeated the first entry", ); const siteListText = assertTerminalOutput( @@ -2527,7 +2549,7 @@ async function runLiveSmoke(env: Record): Promise { assert( codeReadInvalid.exitCode !== 0 && codeReadInvalidEnvelope.code === "INVALID_ARGUMENT" && - codeReadInvalidEnvelope.error.includes("githits code files") && + codeReadInvalidEnvelope.error.includes("githits list") && codeReadInvalidEnvelope.error.includes("githits read") && !codeReadInvalidEnvelope.error.includes("code_files"), "code read invalid json missing CLI-native recovery", @@ -2584,7 +2606,7 @@ async function runLiveSmoke(env: Record): Promise { codeGrepInvalid.exitCode !== 0 && codeGrepInvalidEnvelope.code === "INVALID_ARGUMENT" && codeGrepInvalidEnvelope.error.includes("") && - codeGrepInvalidEnvelope.error.includes("githits code files") && + codeGrepInvalidEnvelope.error.includes("githits list") && !codeGrepInvalidEnvelope.error.includes("code_files"), "code grep invalid json missing CLI-native recovery", ); diff --git a/scripts/smoke-scripts.test.ts b/scripts/smoke-scripts.test.ts index 86406cac..9540e568 100644 --- a/scripts/smoke-scripts.test.ts +++ b/scripts/smoke-scripts.test.ts @@ -538,18 +538,19 @@ describe("smoke script options", () => { }); it("keeps curated CLI parity fixtures on compact package targets", () => { - const compactMcpTools = new Set([ - "docs_list", + const compactPackageFixtureNames = new Set([ + "list_package", "pkg_info", "pkg_vulns", "pkg_deps", + "pkg_deps_issues", ]); - const compactPackageFixtures = JSON_PARITY_FIXTURES.filter(({ mcpTool }) => - compactMcpTools.has(mcpTool), + const compactPackageFixtures = JSON_PARITY_FIXTURES.filter(({ name }) => + compactPackageFixtureNames.has(name), ); expect( new Set(compactPackageFixtures.map(({ mcpTool }) => mcpTool)), - ).toEqual(compactMcpTools); + ).toEqual(new Set(["list", "pkg_info", "pkg_vulns", "pkg_deps"])); for (const fixture of compactPackageFixtures) { expect(typeof fixture.mcpArgs.target, `${fixture.name} target`).toBe( @@ -597,24 +598,46 @@ describe("smoke script options", () => { mcpArgs: { target: "npm:express", format: "json" }, }, { - name: "docs_list", + name: "list_package", cliArgs: [ - "docs", "list", "npm:express@5.2.1", + "package.json", "--limit", - "2", + "1", "--json", ], - mcpTool: "docs_list", + mcpTool: "list", mcpArgs: { target: "npm:express@5.2.1", - limit: 2, + paths: ["package.json"], + limit: 1, format: "json", }, }, ]); + expect( + JSON_PARITY_FIXTURES.find(({ name }) => name === "list_site"), + ).toEqual({ + name: "list_site", + cliArgs: [ + "list", + "site:expressjs.com", + "en/resources/", + "--limit", + "20", + "--json", + ], + mcpTool: "list", + mcpArgs: { + target: "site:expressjs.com", + paths: ["en/resources/"], + limit: 20, + format: "json", + }, + }); + expect( JSON_PARITY_FIXTURES.find(({ name }) => name === "pkg_changelog") ?.mcpArgs, diff --git a/scripts/validate-public-packages.ts b/scripts/validate-public-packages.ts index 617bc11d..807633a4 100644 --- a/scripts/validate-public-packages.ts +++ b/scripts/validate-public-packages.ts @@ -758,7 +758,7 @@ async function verifyMcpConsumer( await writeFile( join(appDirectory, "runtime-check.mjs"), - `import { createMcpServer, getMcpToolDescriptors } from "@githits/mcp";\nimport * as clientEntry from "@githits/mcp/client";\nimport { CodeNavigationServiceImpl, PackageIntelligenceServiceImpl, GitHitsServiceImpl, createClientHeaderBuilder, createStaticTokenProvider, getApiUrl } from "@githits/mcp/client";\nimport { EXPECTED_MCP_TOOLS, runMcpSmoke } from "@githits/mcp/smoke-test";\nimport { Client } from "@modelcontextprotocol/sdk/client/index.js";\nimport { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";\nfor (const removed of ["startTelemetrySpan", "endTelemetrySpan", "flushTelemetry", "withTelemetrySpan"]) {\n if (removed in clientEntry) throw new Error(\`removed client export was present: \${removed}\`);\n}\nif (typeof createMcpServer !== "function") throw new Error("missing createMcpServer");\nif (typeof CodeNavigationServiceImpl !== "function") throw new Error("missing CodeNavigationServiceImpl");\nif (typeof PackageIntelligenceServiceImpl !== "function") throw new Error("missing PackageIntelligenceServiceImpl");\nif (typeof GitHitsServiceImpl !== "function") throw new Error("missing GitHitsServiceImpl");\nif (typeof createStaticTokenProvider !== "function") throw new Error("missing createStaticTokenProvider");\nif (typeof createClientHeaderBuilder !== "function") throw new Error("missing createClientHeaderBuilder");\nif (typeof getApiUrl !== "function") throw new Error("missing getApiUrl");\nif (typeof runMcpSmoke !== "function") throw new Error("missing runMcpSmoke");\nif (EXPECTED_MCP_TOOLS.length === 0) throw new Error("missing expected smoke tools");\nif (getMcpToolDescriptors().length === 0) throw new Error("missing descriptors");\nconst presetChecks = [\n [clientEntry.getMcpUrl, "https://mcp.githits.com", "https://mcp-dev.githits.com"],\n [clientEntry.getApiUrl, "https://api.githits.com", "https://api-dev.githits.com"],\n [clientEntry.getCodeNavigationUrl, "https://oss.githits.dev", "https://oss-dev.githits.dev"],\n];\nfor (const [getter, prod, dev] of presetChecks) {\n if (getter({}) !== prod) throw new Error("packed production preset mismatch");\n if (getter({ GITHITS_ENV: "dev" }) !== dev) throw new Error("packed development preset mismatch");\n}\nif (clientEntry.getCodeNavigationUrl({ GITHITS_ENV: "dev", GITHITS_CODE_NAV_URL: "http://localhost:4000" }) !== "http://localhost:4000") throw new Error("packed override mismatch");\nconsole.log("Packed client presets and independent OSS override passed");\nconst rateLimitedFetch = async () => new Response(null, { status: 429, headers: { "Retry-After": "17" } });\nconst rateLimitedService = new GitHitsServiceImpl("https://example.invalid", "test-token", rateLimitedFetch);\nconst rateLimitedServer = createMcpServer({\n metadata: { name: "packed-consumer", version: "0.0.0" },\n services: {\n githitsService: rateLimitedService,\n codeNavigationService: {},\n packageIntelligenceService: {},\n },\n});\nconst rateLimitedClient = new Client({ name: "packed-consumer", version: "0.0.0" });\nconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\nawait rateLimitedServer.connect(serverTransport);\nawait rateLimitedClient.connect(clientTransport);\nconst rateLimitedResult = await rateLimitedClient.callTool({ name: "get_example", arguments: { query: "package boundary check" } });\nconst rateLimitedText = rateLimitedResult.content?.[0]?.text;\nif (typeof rateLimitedText !== "string") throw new Error("missing packed rate-limit payload");\nconst rateLimitedPayload = JSON.parse(rateLimitedText);\nif (rateLimitedResult.isError !== true) throw new Error("packed rate-limit result was not an error");\nif (rateLimitedPayload.code !== "RATE_LIMITED") throw new Error(\`packed rate-limit code was \${String(rateLimitedPayload.code)}\`);\nif (rateLimitedPayload.retryable !== true) throw new Error("packed rate-limit result was not retryable");\nif (rateLimitedPayload.details?.status !== 429) throw new Error("packed rate-limit status metadata was missing");\nif (rateLimitedPayload.details?.retryAfterSeconds !== 17) throw new Error("packed retry timing metadata was missing");\nawait rateLimitedClient.close();\nawait rateLimitedServer.close();\ntry {\n await import("@githits/mcp/internal");\n throw new Error("internal export resolved");\n} catch (error) {\n if (error instanceof Error && error.message === "internal export resolved") throw error;\n}\n`, + `import { createMcpServer, getMcpToolDescriptors } from "@githits/mcp";\nimport * as clientEntry from "@githits/mcp/client";\nimport { CodeNavigationServiceImpl, PackageIntelligenceServiceImpl, GitHitsServiceImpl, ListServiceImpl, createClientHeaderBuilder, createStaticTokenProvider, getApiUrl } from "@githits/mcp/client";\nimport { EXPECTED_MCP_TOOLS, runMcpSmoke } from "@githits/mcp/smoke-test";\nimport { Client } from "@modelcontextprotocol/sdk/client/index.js";\nimport { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";\nfor (const removed of ["startTelemetrySpan", "endTelemetrySpan", "flushTelemetry", "withTelemetrySpan"]) {\n if (removed in clientEntry) throw new Error(\`removed client export was present: \${removed}\`);\n}\nif (typeof createMcpServer !== "function") throw new Error("missing createMcpServer");\nif (typeof CodeNavigationServiceImpl !== "function") throw new Error("missing CodeNavigationServiceImpl");\nif (typeof PackageIntelligenceServiceImpl !== "function") throw new Error("missing PackageIntelligenceServiceImpl");\nif (typeof GitHitsServiceImpl !== "function") throw new Error("missing GitHitsServiceImpl");\nif (typeof ListServiceImpl !== "function") throw new Error("missing ListServiceImpl");\nif (typeof createStaticTokenProvider !== "function") throw new Error("missing createStaticTokenProvider");\nif (typeof createClientHeaderBuilder !== "function") throw new Error("missing createClientHeaderBuilder");\nif (typeof getApiUrl !== "function") throw new Error("missing getApiUrl");\nif (typeof runMcpSmoke !== "function") throw new Error("missing runMcpSmoke");\nif (EXPECTED_MCP_TOOLS.length === 0) throw new Error("missing expected smoke tools");\nif (getMcpToolDescriptors().length === 0) throw new Error("missing descriptors");\nconst presetChecks = [\n [clientEntry.getMcpUrl, "https://mcp.githits.com", "https://mcp-dev.githits.com"],\n [clientEntry.getApiUrl, "https://api.githits.com", "https://api-dev.githits.com"],\n [clientEntry.getCodeNavigationUrl, "https://oss.githits.dev", "https://oss-dev.githits.dev"],\n];\nfor (const [getter, prod, dev] of presetChecks) {\n if (getter({}) !== prod) throw new Error("packed production preset mismatch");\n if (getter({ GITHITS_ENV: "dev" }) !== dev) throw new Error("packed development preset mismatch");\n}\nif (clientEntry.getCodeNavigationUrl({ GITHITS_ENV: "dev", GITHITS_CODE_NAV_URL: "http://localhost:4000" }) !== "http://localhost:4000") throw new Error("packed override mismatch");\nconsole.log("Packed client presets and independent OSS override passed");\nconst rateLimitedFetch = async () => new Response(null, { status: 429, headers: { "Retry-After": "17" } });\nconst rateLimitedService = new GitHitsServiceImpl("https://example.invalid", "test-token", rateLimitedFetch);\nconst rateLimitedServer = createMcpServer({\n metadata: { name: "packed-consumer", version: "0.0.0" },\n services: {\n githitsService: rateLimitedService,\n codeNavigationService: {},\n packageIntelligenceService: {},\n listService: {},\n },\n});\nconst rateLimitedClient = new Client({ name: "packed-consumer", version: "0.0.0" });\nconst [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();\nawait rateLimitedServer.connect(serverTransport);\nawait rateLimitedClient.connect(clientTransport);\nconst rateLimitedResult = await rateLimitedClient.callTool({ name: "get_example", arguments: { query: "package boundary check" } });\nconst rateLimitedText = rateLimitedResult.content?.[0]?.text;\nif (typeof rateLimitedText !== "string") throw new Error("missing packed rate-limit payload");\nconst rateLimitedPayload = JSON.parse(rateLimitedText);\nif (rateLimitedResult.isError !== true) throw new Error("packed rate-limit result was not an error");\nif (rateLimitedPayload.code !== "RATE_LIMITED") throw new Error(\`packed rate-limit code was \${String(rateLimitedPayload.code)}\`);\nif (rateLimitedPayload.retryable !== true) throw new Error("packed rate-limit result was not retryable");\nif (rateLimitedPayload.details?.status !== 429) throw new Error("packed rate-limit status metadata was missing");\nif (rateLimitedPayload.details?.retryAfterSeconds !== 17) throw new Error("packed retry timing metadata was missing");\nawait rateLimitedClient.close();\nawait rateLimitedServer.close();\ntry {\n await import("@githits/mcp/internal");\n throw new Error("internal export resolved");\n} catch (error) {\n if (error instanceof Error && error.message === "internal export resolved") throw error;\n}\n`, ); await runCommand( "node", @@ -779,12 +779,25 @@ void new ReadServiceImpl("https://example.invalid", createStaticTokenProvider("t appDirectory, "runtime import packed read service", ); + await writeFile( + join(appDirectory, "list-service-runtime-check.mjs"), + `import { ListServiceImpl, createStaticTokenProvider } from "@githits/mcp/client"; +if (typeof ListServiceImpl !== "function") throw new Error("missing ListServiceImpl"); +void new ListServiceImpl("https://example.invalid", createStaticTokenProvider("token")); +`, + ); + await runCommand( + "node", + [join(appDirectory, "list-service-runtime-check.mjs")], + appDirectory, + "runtime import packed list service", + ); await verifyMcpToolsBrowserConsumer(appDirectory); await verifyMcpBundleProbes(appDirectory); await writeFile( join(appDirectory, "check.ts"), - `import { buildMcpQuickStart, createMcpServer, getMcpToolDescriptors, type CreateMcpServerOptions, type McpToolServicesProvider } from "@githits/mcp";\nimport { CodeNavigationServiceImpl, GitHitsServiceImpl, PackageIntelligenceServiceImpl, createClientHeaderBuilder, createStaticTokenProvider, getApiUrl, getCodeNavigationUrl, type GitHitsService, type ServiceDiagnostics, type TokenProvider } from "@githits/mcp/client";\nimport { EXPECTED_MCP_TOOLS, runMcpSmoke, type McpSmokeCaller } from "@githits/mcp/smoke-test";\n// These negative imports guard the packed declaration surface if old globals reappear.\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { startTelemetrySpan } from "@githits/mcp/client";\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { endTelemetrySpan } from "@githits/mcp/client";\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { flushTelemetry } from "@githits/mcp/client";\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { withTelemetrySpan } from "@githits/mcp/client";\nconst provider: McpToolServicesProvider = () => { throw new Error("unused"); };\nconst options: CreateMcpServerOptions = { authAction: "Authenticate with the hosted GitHits MCP server.", metadata: { name: "consumer", version: "0.0.0" }, services: provider };\nconst tokenProvider: TokenProvider = createStaticTokenProvider("token");\nconst headers = createClientHeaderBuilder({ clientName: "remote-mcp", clientVersion: "0.0.0" });\nconst diagnostics: ServiceDiagnostics = { withOperation: async (_name, operation) => operation(), isEnabled: () => false, debug: () => {} };\nconst gitHitsService: GitHitsService = new GitHitsServiceImpl(getApiUrl(), "token", undefined, undefined, { clientHeaders: headers, userAgent: "remote-mcp/0.0.0", diagnostics });\nconst caller: McpSmokeCaller = { listTools: async () => ({ tools: EXPECTED_MCP_TOOLS.map((name) => ({ name })) }), callTool: async (name) => ({ content: [{ type: "text", text: name === "quick_start" ? buildMcpQuickStart() : "ok" }] }) };\nvoid new CodeNavigationServiceImpl(getCodeNavigationUrl({ GITHITS_ENV: "dev" }), tokenProvider, globalThis.fetch, { clientHeaders: headers, userAgent: "remote-mcp/0.0.0", diagnostics });\nvoid new PackageIntelligenceServiceImpl(getCodeNavigationUrl(), tokenProvider, globalThis.fetch, { clientHeaders: headers, userAgent: "remote-mcp/0.0.0", diagnostics });\nvoid gitHitsService;\nvoid createMcpServer(options);\nvoid buildMcpQuickStart();\nvoid runMcpSmoke(caller, { includeLiveTools: false });\nif (getMcpToolDescriptors().length === 0) throw new Error("expected descriptors");\n`, + `import { buildMcpQuickStart, createMcpServer, getMcpToolDescriptors, type CreateMcpServerOptions, type McpToolServicesProvider } from "@githits/mcp";\nimport { CodeNavigationServiceImpl, GitHitsServiceImpl, ListServiceImpl, PackageIntelligenceServiceImpl, createClientHeaderBuilder, createStaticTokenProvider, getApiUrl, getCodeNavigationUrl, type GitHitsService, type ServiceDiagnostics, type TokenProvider } from "@githits/mcp/client";\nimport { EXPECTED_MCP_TOOLS, runMcpSmoke, type McpSmokeCaller } from "@githits/mcp/smoke-test";\n// These negative imports guard the packed declaration surface if old globals reappear.\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { startTelemetrySpan } from "@githits/mcp/client";\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { endTelemetrySpan } from "@githits/mcp/client";\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { flushTelemetry } from "@githits/mcp/client";\n// @ts-expect-error telemetry lifecycle was removed from the client entry\nimport { withTelemetrySpan } from "@githits/mcp/client";\nconst provider: McpToolServicesProvider = () => { throw new Error("unused"); };\nconst options: CreateMcpServerOptions = { authAction: "Authenticate with the hosted GitHits MCP server.", metadata: { name: "consumer", version: "0.0.0" }, services: provider };\nconst tokenProvider: TokenProvider = createStaticTokenProvider("token");\nconst headers = createClientHeaderBuilder({ clientName: "remote-mcp", clientVersion: "0.0.0" });\nconst diagnostics: ServiceDiagnostics = { withOperation: async (_name, operation) => operation(), isEnabled: () => false, debug: () => {} };\nconst gitHitsService: GitHitsService = new GitHitsServiceImpl(getApiUrl(), "token", undefined, undefined, { clientHeaders: headers, userAgent: "remote-mcp/0.0.0", diagnostics });\nconst caller: McpSmokeCaller = { listTools: async () => ({ tools: EXPECTED_MCP_TOOLS.map((name) => ({ name })) }), callTool: async (name) => ({ content: [{ type: "text", text: name === "quick_start" ? buildMcpQuickStart() : "ok" }] }) };\nvoid new CodeNavigationServiceImpl(getCodeNavigationUrl({ GITHITS_ENV: "dev" }), tokenProvider, globalThis.fetch, { clientHeaders: headers, userAgent: "remote-mcp/0.0.0", diagnostics });\nvoid new ListServiceImpl(getCodeNavigationUrl(), tokenProvider, globalThis.fetch, { clientHeaders: headers, userAgent: "remote-mcp/0.0.0", diagnostics });\nvoid new PackageIntelligenceServiceImpl(getCodeNavigationUrl(), tokenProvider, globalThis.fetch, { clientHeaders: headers, userAgent: "remote-mcp/0.0.0", diagnostics });\nvoid gitHitsService;\nvoid createMcpServer(options);\nvoid buildMcpQuickStart();\nvoid runMcpSmoke(caller, { includeLiveTools: false });\nif (getMcpToolDescriptors().length === 0) throw new Error("expected descriptors");\n`, ); await writeFile( join(appDirectory, "tsconfig.json"), @@ -801,6 +814,7 @@ void new ReadServiceImpl("https://example.invalid", createStaticTokenProvider("t include: [ "check.ts", "code-diff-check.ts", + "list-service-check.ts", "read-service-check.ts", "tools-check.ts", ], @@ -821,6 +835,15 @@ const callableOptions: CallableToolExecutionOptions = { signal: new AbortControl const publicErrors: Error[] = [new AuthenticationError(), new ApiRateLimitError(), new FetchTimeoutError(1_000), new TermsAcceptanceRequiredError()]; void callableTool.execute(callableInput, callableOptions); void publicErrors; +`, + ); + await writeFile( + join(appDirectory, "list-service-check.ts"), + `import type { McpToolServices } from "@githits/mcp"; +import { ListServiceImpl, createStaticTokenProvider, getCodeNavigationUrl, type ListService } from "@githits/mcp/client"; +const listService: ListService = new ListServiceImpl(getCodeNavigationUrl(), createStaticTokenProvider("token")); +const services = { listService } satisfies Pick; +void services; `, ); await writeFile( diff --git a/skills/githits-mcp/SKILL.md b/skills/githits-mcp/SKILL.md index 8c903314..f8d02854 100644 --- a/skills/githits-mcp/SKILL.md +++ b/skills/githits-mcp/SKILL.md @@ -20,9 +20,8 @@ This guide owns shared policy; selected tools own call syntax and exceptions. | --- | --- | | Find a known literal or regex in a public repository/package | `code_grep` | | Find relevant source, symbols, tests, or documentation for a topic | `search` | -| List paths or browse a source directory | `code_files` | +| Browse files or documentation pages in a known package, repository, or site | `list` | | Read a source file, code symbol, or documentation section | `read` | -| Browse package documentation pages | `docs_list` | | Assess a package's license, adoption, maintenance, or overall health | `pkg_info` | | Inspect vulnerabilities in a package or version | `pkg_vulns` | | Inspect direct dependencies or transitive footprint | `pkg_deps` | @@ -43,12 +42,16 @@ Use public repository targets for full repositories or sibling packages: A ref may be a branch, tag, or commit and contain later `@`; `#` is for semantic fragments, not revisions. -For a package or site docs topic, use `search` with `source:"docs"`. -`docs_list` browses package pages, not standalone `site:` targets. -Use snippets when sufficient; otherwise read the target in a `[docs page]` -search header. For an exact section or bounds, request search JSON and replay -its `followUp` unchanged, including supplied `selector` and bounds. -Replay a `docs_list` read action unchanged when browsing package pages. +`list` is for a known target when you need its structure or an exact path; +use `search` for content by topic. A package target covers its own source tree, +while a repository target covers the whole snapshot; both include source and +documentation. Hosted docs use a separate explicit `site:` inventory that +`list` does not discover. For hosted package docs, search the package with +`source:"docs"`, then pass the explicit `site:` target from a `[docs page]` +search header to `list`. Use snippets when sufficient; otherwise read the +target in that header. For an exact section or bounds, request search JSON and +replay its `followUp` unchanged, including supplied `selector` and bounds. +Replay a `list` read action unchanged when browsing paths. Hosted/crawled HTTP(S) docs locators address mutable current content. A direct HTTP(S) docs fragment read without explicit bounds returns its heading and full subtree through the next equal-or-higher heading. diff --git a/src/commands/code/code-nav-cli-helpers.ts b/src/commands/code/code-nav-cli-helpers.ts index 1884dfb1..5473e0b8 100644 --- a/src/commands/code/code-nav-cli-helpers.ts +++ b/src/commands/code/code-nav-cli-helpers.ts @@ -140,7 +140,7 @@ export function formatIndexingError(mapped: MappedError): string { /** * Terminal error renderer for `code read` / `code grep`. Adds the - * `code files` recovery hint for concrete missing-path cases, even + * `githits list` recovery hint for concrete missing-path cases, even * when the backend still collapses them into generic `NOT_FOUND`. * Leaves unrelated repository / indexing-state `NOT_FOUND` errors * alone so we don't send users toward path debugging for the wrong @@ -151,20 +151,20 @@ export function formatFileErrorWithFilesHint(mapped: MappedError): string { return formatMappedErrorForTerminal(mapped); } if (mapped.code === "FILE_NOT_FOUND") { - return `${formatMappedErrorForTerminal(mapped)}\n Use \`code files\` to list available paths.`; + return `${formatMappedErrorForTerminal(mapped)}\n Use \`githits list \` to list available paths.`; } if (isExactPathAuthorityError(mapped)) { const guidance = mapped.code === "FILE_PATH_EXCLUDED" - ? "This path is excluded from the indexed source; use `code files` to list indexed paths." - : "The source inventory cannot verify this path; use `code files` to list indexed paths it can currently verify."; + ? "This path is excluded from the indexed source; use `githits list ` to list indexed paths." + : "The source inventory cannot verify this path; use `githits list ` to list indexed paths it can currently verify."; return `${formatMappedErrorForTerminal(mapped)}\n ${guidance}`; } if ( mapped.code === "NOT_FOUND" && looksLikeMissingFileMessage(mapped.message) ) { - return `${formatMappedErrorForTerminal(mapped)}\n Use \`code files\` to list available paths.`; + return `${formatMappedErrorForTerminal(mapped)}\n Use \`githits list \` to list available paths.`; } if (mapped.code === "REF_NOT_FOUND") { return `${formatMappedErrorForTerminal(mapped)}\n Check that the repository URL and git ref exist and are publicly accessible.`; @@ -239,8 +239,8 @@ function withCliExactPathAuthorityRecovery( const prefix = buildContainingPathPrefix(mapped.details.filePath); const listing = prefix === "" - ? "Use `githits code files` without a path prefix" - : `Use \`githits code files\` with path prefix ${JSON.stringify(prefix)}`; + ? "Use `githits list `" + : `Use \`githits list ${JSON.stringify(prefix)}\``; const reason = mapped.code === "FILE_PATH_EXCLUDED" ? "This path is excluded from the indexed source." @@ -277,8 +277,8 @@ function withCliPathRecovery( : ""; const listing = prefix === "" - ? "Use `githits code files` without a path prefix" - : `Use \`githits code files\` with path prefix ${JSON.stringify(prefix)}`; + ? "Use `githits list `" + : `Use \`githits list ${JSON.stringify(prefix)}\``; return { ...mapped, @@ -306,7 +306,7 @@ function looksLikeMissingNavpackMessage(message: string): boolean { * * Each command passes its own `terminalRenderer` so the hint * message can differ (e.g. `code files` doesn't need the - * `code files`-as-recovery hint; `code read` / `code grep` do). + * `githits list`-as-recovery hint; `code read` / `code grep` do). * * `exitCode` defaults to 1; `code grep` overrides to 2 so callers * can distinguish "no matches" (exit 1, `grep` convention) from diff --git a/src/commands/code/grep.test.ts b/src/commands/code/grep.test.ts index 92d91ff6..0dacb915 100644 --- a/src/commands/code/grep.test.ts +++ b/src/commands/code/grep.test.ts @@ -512,8 +512,10 @@ describe("pkgGrepAction", () => { }; expect(payload.code).toBe("INVALID_ARGUMENT"); expect(payload.error).toContain("`` is required"); - expect(payload.error).toContain("`githits code files`"); + expect(payload.error).toContain("`githits list `"); expect(payload.error).not.toContain("code_files"); + expect(payload.error).not.toContain("path_prefix"); + expect(payload.error).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -822,7 +824,9 @@ describe("pkgGrepAction", () => { /* expected */ } - expect(errorSpy.mock.calls[0]?.[0]).not.toContain("Use `code files`"); + expect(errorSpy.mock.calls[0]?.[0]).not.toContain( + "Use `githits list `", + ); errorSpy.mockReset(); @@ -849,7 +853,9 @@ describe("pkgGrepAction", () => { /* expected */ } - expect(errorSpy.mock.calls[0]?.[0]).toContain("Use `code files`"); + expect(errorSpy.mock.calls[0]?.[0]).toContain( + "Use `githits list `", + ); errorSpy.mockRestore(); exitSpy.mockRestore(); }); @@ -894,7 +900,7 @@ describe("pkgGrepAction", () => { try { const output = String(errorSpy.mock.calls[0]?.[0]); expect(output).toContain(expectedGuidance); - expect(output).toContain("`code files`"); + expect(output).toContain("`githits list "); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -938,12 +944,15 @@ describe("pkgGrepAction", () => { }; expect(payload.code).toBe("FILE_NOT_FOUND"); expect(payload.details?.filePath).toBe("docs/missing.md"); - expect(payload.details?.action).toContain("`githits code files`"); - expect(payload.details?.action).toContain('path prefix "docs/"'); + expect(payload.details?.action).toContain( + '`githits list "docs/"`', + ); expect(payload.details?.action).toContain("`--path `"); expect(payload.details?.action).toContain("`githits code grep`"); expect(payload.details?.action).not.toContain("code_files"); expect(payload.details?.action).not.toContain("code_grep"); + expect(payload.details?.action).not.toContain("path_prefix"); + expect(payload.details?.action).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -983,8 +992,13 @@ describe("pkgGrepAction", () => { const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string) as { details?: { action?: string }; }; - expect(payload.details?.action).toContain('path prefix "benchmarks/"'); + expect(payload.details?.action).toContain( + '`githits list "benchmarks/"`', + ); expect(payload.details?.action).not.toContain("benchmarks/run/"); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); + expect(payload.details?.action).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); diff --git a/src/commands/code/grep.ts b/src/commands/code/grep.ts index b48da563..7fd61272 100644 --- a/src/commands/code/grep.ts +++ b/src/commands/code/grep.ts @@ -235,8 +235,8 @@ function collectRepeatable(value: string, previous: string[] = []): string[] { } /** - * Translate CLI-reachable, backtick-delimited MCP validation tokens. Anchored - * rules prevent replacements inside user values echoed after `Got:`. + * Translate CLI-reachable MCP validation wording. Anchored and exact + * replacements leave user values echoed after `Got:` unchanged. */ function buildCliGrepParams( input: GrepRepoRequestInput, @@ -250,7 +250,7 @@ function buildCliGrepParams( .replace(/`globs`/g, "`--glob`") .replace(/`extensions`/g, "`--ext`") .replace(/^`symbol_fields`/, "`--symbol-field`") - .replace(/`code_files`/g, "`githits code files`"); + .replace("use `list` instead.", "use `githits list ` instead."); if (rewritten === error.message) throw error; throw new InvalidPackageSpecError(rewritten); } @@ -288,7 +288,7 @@ repeatable --ext for extension filtering. When [path-prefix], --path, and use --ext to narrow further (intersection). If an exact --path is missing, excluded, or cannot be verified by the source -inventory, use \`code files\` to inspect the indexed paths. +inventory, use \`githits list \` to inspect the indexed paths. Default output is \`file:line:text\`, pipe-friendly like grep. Use -C / -A / -B for context, --verbose for grouped output, and --cursor to continue a paginated diff --git a/src/commands/code/read.test.ts b/src/commands/code/read.test.ts index 4bcbda07..d392fcfc 100644 --- a/src/commands/code/read.test.ts +++ b/src/commands/code/read.test.ts @@ -354,14 +354,14 @@ describe("pkgReadAction", () => { }; expect(payload.code).toBe("INVALID_ARGUMENT"); expect(payload.error).toContain("`` must be an exact file path"); - expect(payload.error).toContain("`githits code files`"); - expect(payload.error).toContain('path prefix "lib/"'); + expect(payload.error).toContain('`githits list "lib/"`'); expect(payload.error).toContain("`githits read`"); expect(payload.error).not.toContain("emitted `path`"); expect(payload.error).not.toContain("`file_path`"); expect(payload.error).not.toContain("code_files"); expect(payload.error).not.toContain("code_read"); expect(payload.error).not.toContain("path_prefix"); + expect(payload.error).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -391,8 +391,8 @@ describe("pkgReadAction", () => { error: string; }; expect(payload.code).toBe("INVALID_ARGUMENT"); - expect(payload.error).toContain('path prefix "li`b/"'); - expect(payload.error).not.toContain('path prefix "lib/"'); + expect(payload.error).toContain('`githits list "li`b/"`'); + expect(payload.error).not.toContain('`githits list "lib/"`'); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -463,7 +463,7 @@ describe("pkgReadAction", () => { writeSpy.mockRestore(); }); - it("routes NOT_FOUND on missing path with a code-files hint (backend currently emits NOT_FOUND, not FILE_NOT_FOUND)", async () => { + it("routes NOT_FOUND on missing path with a list hint (backend currently emits NOT_FOUND, not FILE_NOT_FOUND)", async () => { const errorSpy = spyOn(console, "error").mockImplementation(() => {}); const exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); @@ -487,12 +487,12 @@ describe("pkgReadAction", () => { } const output = errorSpy.mock.calls[0]?.[0] as string; expect(output).toContain("File not found"); - expect(output).toContain("code files"); + expect(output).toContain("githits list "); errorSpy.mockRestore(); exitSpy.mockRestore(); }); - it("routes FILE_NOT_FOUND with a code-files hint", async () => { + it("routes FILE_NOT_FOUND with a list hint", async () => { const errorSpy = spyOn(console, "error").mockImplementation(() => {}); const exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); @@ -519,7 +519,7 @@ describe("pkgReadAction", () => { } const output = errorSpy.mock.calls[0]?.[0] as string; expect(output).toContain("File not found"); - expect(output).toContain("code files"); + expect(output).toContain("githits list "); errorSpy.mockRestore(); exitSpy.mockRestore(); }); @@ -556,11 +556,14 @@ describe("pkgReadAction", () => { const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string) as { details?: { action?: string }; }; - expect(payload.details?.action).toContain("`githits code files`"); - expect(payload.details?.action).toContain('path prefix "docs/"'); + expect(payload.details?.action).toContain( + '`githits list "docs/"`', + ); expect(payload.details?.action).toContain("`githits read`"); expect(payload.details?.action).not.toContain("code_files"); expect(payload.details?.action).not.toContain("code_read"); + expect(payload.details?.action).not.toContain("path_prefix"); + expect(payload.details?.action).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -608,10 +611,12 @@ describe("pkgReadAction", () => { details?: { action?: string }; }; expect(payload.details?.action).toContain(expectedGuidance); - expect(payload.details?.action).toContain("`githits code files`"); + expect(payload.details?.action).toContain("`githits list "); expect(payload.details?.action).toContain("`githits read`"); expect(payload.details?.action).not.toContain("code_files"); expect(payload.details?.action).not.toContain("code_read"); + expect(payload.details?.action).not.toContain("path_prefix"); + expect(payload.details?.action).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -651,9 +656,14 @@ describe("pkgReadAction", () => { const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string) as { details?: { action?: string }; }; - expect(payload.details?.action).toContain('path prefix "lib/"'); + expect(payload.details?.action).toContain( + '`githits list "lib/"`', + ); expect(payload.details?.action).not.toContain("./lib/"); expect(payload.details?.action).not.toContain("lib/internal/"); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); + expect(payload.details?.action).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -692,8 +702,13 @@ describe("pkgReadAction", () => { details?: { action?: string }; }; expect(payload.details?.action).toContain("`githits read`"); - expect(payload.details?.action).toContain('path prefix "lib/"'); + expect(payload.details?.action).toContain( + '`githits list "lib/"`', + ); expect(payload.details?.action).not.toContain("code_read"); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); + expect(payload.details?.action).not.toContain("paths:"); } finally { errorSpy.mockRestore(); exitSpy.mockRestore(); @@ -769,7 +784,7 @@ describe("pkgReadAction", () => { expect(output).toContain("Git ref not found: HEAD"); expect(output).toContain("repository URL and git ref"); expect(output).not.toContain("Narrow the target"); - expect(output).not.toContain("code files"); + expect(output).not.toContain("githits list "); errorSpy.mockRestore(); exitSpy.mockRestore(); }); diff --git a/src/commands/code/read.ts b/src/commands/code/read.ts index 45b9fe13..0b0a27e4 100644 --- a/src/commands/code/read.ts +++ b/src/commands/code/read.ts @@ -152,8 +152,8 @@ export async function pkgReadAction( } /** - * Translate CLI-reachable MCP validation tokens. Unchanged errors are rethrown - * so this boundary does not mask unrelated shared validation failures. + * Translate shared validation failures to CLI-native argument names and + * listing syntax. Unchanged errors are rethrown at this boundary. */ export function buildCliReadFileParams( input: ReadFileRequestInput, @@ -162,15 +162,16 @@ export function buildCliReadFileParams( return buildReadFileParams(input); } catch (error) { if (!(error instanceof InvalidPackageSpecError)) throw error; + const filePath = input.filePath.trim(); + if (filePath.endsWith("/")) { + throw new InvalidPackageSpecError( + `\`\` must be an exact file path, not a directory prefix. Use \`githits list ${JSON.stringify(filePath)}\` to list files, then pass an emitted path to \`githits read\`.`, + ); + } const rewritten = error.message - .replace(/`file_path`/g, "``") + .replace(/^`file_path`/, "``") .replace("start_line (", "--start (") .replace("end_line (", "--end (") - .replace(/`code_files`/g, "`githits code files`") - .replace( - /`path_prefix: ([\s\S]+)` to list files/g, - "path prefix $1 to list files", - ) .replace(/emitted `path`/g, "emitted path") .replace(/`read`/g, "`githits read`"); if (rewritten === error.message) throw error; diff --git a/src/commands/list.test.ts b/src/commands/list.test.ts index 31059691..e3834154 100644 --- a/src/commands/list.test.ts +++ b/src/commands/list.test.ts @@ -422,7 +422,13 @@ describe("unified list CLI", () => { createDeps({ listService: service }), ); expect(writes.join("")).toBe( - `${['# source npm:express@5.2.1 | follow up with "read npm:express@5.2.1 $path" | more results available', "src/index.ts"].join("\n")}\n`, + `${[ + '# source npm:express@5.2.1 | follow up with "read npm:express@5.2.1 $path" | more results available', + "src/index.ts", + "", + "More results: reuse the same target, paths, and options with:", + " --after 'next/%2F cursor'", + ].join("\n")}\n`, ); } finally { write.mockRestore(); diff --git a/src/commands/list.ts b/src/commands/list.ts index 5a2f10f2..cfe63fba 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -90,6 +90,7 @@ export async function listAction( const output = formatListText(projected, { useColors: shouldUseColors(), includeHeader: !options.silent, + syntax: "cli", }); if (output.length > 0) process.stdout.write(`${output}\n`); } @@ -169,7 +170,7 @@ export function registerListCommand( .command("list") .summary("List files and documentation in a target") .description( - "List the files and documentation entries for one package, repository, or hosted site. Package and repository targets stay within their source inventory; use site: for hosted documentation. Pass paths as literals or globs, and add --recursive to traverse matched directories. Text output is one path per line; use --silent for paths only or --json for actions, cursors, and metadata.", + "List files and documentation paths in one package, repository, or hosted site. Paths are target-relative literals or globs for every target. Directories show immediate children; add --recursive to expand them. Text includes read and continuation guidance. Use --silent for paths only and --json only for programmatic processing.", ) .argument("", "Package, repository, or site target") .argument("[paths...]", "Literal path selectors or glob patterns") diff --git a/src/commands/mcp-instructions.test.ts b/src/commands/mcp-instructions.test.ts index d95ffa54..4080bae1 100644 --- a/src/commands/mcp-instructions.test.ts +++ b/src/commands/mcp-instructions.test.ts @@ -5,6 +5,7 @@ import { EXTERNAL_CONTENT_POSTURE } from "../../packages/mcp/src/tools/guardrail import { createMockCodeNavigationService, createMockGitHitsService, + createMockListService, createMockPackageIntelligenceService, createMockReadService, } from "../services/test-helpers.js"; @@ -16,6 +17,7 @@ function createTestServices( codeNavigationService: createMockCodeNavigationService(), packageIntelligenceService: createMockPackageIntelligenceService(), githitsService: createMockGitHitsService(), + listService: createMockListService(), readService: createMockReadService(), ...overrides, }; @@ -25,10 +27,9 @@ const KNOWN_TOOLS = [ "search", "get_example", "search_status", - "code_files", + "list", "read", "code_grep", - "docs_list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -61,8 +62,10 @@ describe("buildMcpQuickStart", () => { "Find a known literal or regex in a public repository/package | `code_grep`", ); expect(instructions).toContain( - "List paths or browse a source directory | `code_files`", + "Browse files or documentation pages in a known package, repository, or site | `list`", ); + expect(instructions).not.toContain("`code_files`"); + expect(instructions).not.toContain("`docs_list`"); expect(instructions).toContain( "Compare current and target dependency versions for an upgrade | `pkg_upgrade_review`", ); @@ -124,20 +127,22 @@ describe("buildMcpQuickStart", () => { expect(instructions).toContain( "Reuse returned targets, paths, locators, references, and ranges", ); - expect(instructions).toContain( - 'For a package or site docs topic, use `search` with `source:"docs"`', + expect(instructions).toMatch( + /For hosted package docs, search the package with\s+`source:"docs"`, then pass the explicit `site:` target from a `\[docs page\]`\s+search header to `list`/, ); expect(instructions).toContain( - "`docs_list` browses package pages, not standalone `site:` targets", + "`list` is for a known target when you need its structure or an exact path", ); + expect(instructions).not.toContain("`code_files`"); + expect(instructions).not.toContain("`docs_list`"); expect(instructions).toContain( - "Use snippets when sufficient; otherwise read the target in a `[docs page]`", + "Use snippets when sufficient; otherwise read the\ntarget in that header", ); expect(instructions).toContain( "its `followUp` unchanged, including supplied `selector` and bounds", ); expect(instructions).toContain( - "search header. For an exact section or bounds, request search JSON", + "target in that header. For an exact section or bounds, request search JSON", ); expect(instructions).toContain( "Hosted/crawled HTTP(S) docs locators address mutable current content", @@ -179,6 +184,8 @@ describe("buildMcpQuickStart", () => { const mentioned = mentionedTools(buildMcpQuickStart()); const registered = registeredTools(services); + expect(registered.size).toBe(12); + expect(mentioned).toEqual(new Set(KNOWN_TOOLS)); for (const name of mentioned) { expect(registered.has(name)).toBe(true); } @@ -186,10 +193,9 @@ describe("buildMcpQuickStart", () => { const packageAndCodeTools = [ "search", "search_status", - "code_files", + "list", "read", "code_grep", - "docs_list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -222,6 +228,9 @@ describe("buildMcpQuickStart", () => { expect(descriptions.get("search")).toStartWith( "Discover relevant docs, code, and symbols in a known public target", ); + expect(descriptions.get("list")).toStartWith( + "List files and documentation paths in a known package, repository, or site.", + ); expect(descriptions.get("code_grep")).toStartWith( "Find text, regex, or identifier matches in a public repo or package", ); diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index da6a3cbd..9dfcc9b7 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -28,6 +28,7 @@ import { Command } from "commander"; import { createMockCodeNavigationService, createMockGitHitsService, + createMockListService, createMockPackageIntelligenceService, createMockReadService, createMockResolveTargetService, @@ -79,6 +80,7 @@ function createTestServices( codeNavigationService: createMockCodeNavigationService(), packageIntelligenceService: createMockPackageIntelligenceService(), githitsService: createMockGitHitsService(), + listService: createMockListService(), readService: createMockReadService(), resolveTargetService: createMockResolveTargetService(), ...overrides, @@ -90,10 +92,9 @@ const EXPECTED_TOOL_NAMES = [ "get_example", "search", "search_status", - "code_files", + "list", "read", "code_grep", - "docs_list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -125,8 +126,11 @@ describe("createMcpServer", () => { const tools = getMcpToolDefinitions(services); expect(tools.map((tool) => tool.name)).toEqual([...EXPECTED_TOOL_NAMES]); - expect(tools.map((tool) => tool.name)).toHaveLength(13); + expect(tools.map((tool) => tool.name)).toHaveLength(12); expect(tools.map((tool) => tool.name)).toContain("read"); + expect(tools.map((tool) => tool.name)).toContain("list"); + expect(tools.map((tool) => tool.name)).not.toContain("code_files"); + expect(tools.map((tool) => tool.name)).not.toContain("docs_list"); expect(tools.map((tool) => tool.name)).not.toContain("code_read"); expect(tools.map((tool) => tool.name)).not.toContain("docs_read"); }); @@ -369,7 +373,7 @@ describe("createMcpServer", () => { "get_example", "search", "search_status", - "code_files", + "list", "read", "code_grep", ]) { @@ -381,7 +385,9 @@ describe("createMcpServer", () => { const services = createTestServices(); const tools = getMcpToolDefinitions(services); - expect(tools.map((tool) => tool.name)).toContain("docs_list"); + expect(tools.map((tool) => tool.name)).toContain("list"); + expect(tools.map((tool) => tool.name)).not.toContain("code_files"); + expect(tools.map((tool) => tool.name)).not.toContain("docs_list"); expect(tools.map((tool) => tool.name)).toContain("read"); expect(tools.map((tool) => tool.name)).not.toContain("code_read"); expect(tools.map((tool) => tool.name)).not.toContain("docs_read"); diff --git a/src/commands/read.test.ts b/src/commands/read.test.ts index b3c020fc..09679974 100644 --- a/src/commands/read.test.ts +++ b/src/commands/read.test.ts @@ -355,8 +355,13 @@ describe("top-level read", () => { readAction("npm:express", "docs/missing.md", { json: true }, services), ).rejects.toThrow("exit"); const payload = JSON.parse(String(error.mock.calls[0]?.[0])); - expect(payload.details.action).toContain("`githits code files`"); + expect(payload.details.action).toContain( + '`githits list "docs/"`', + ); expect(payload.details.action).toContain("`githits read`"); + expect(payload.details.action).not.toContain("code_files"); + expect(payload.details.action).not.toContain("path_prefix"); + expect(payload.details.action).not.toContain("paths:"); } finally { error.mockRestore(); exit.mockRestore(); @@ -382,7 +387,7 @@ describe("top-level read", () => { readAction("npm:express", "docs/missing.md", {}, services), ).rejects.toThrow("exit"); expect(String(error.mock.calls[0]?.[0])).toContain( - "Use `code files` to list available paths.", + "Use `githits list ` to list available paths.", ); } finally { error.mockRestore(); diff --git a/src/mcp-public-surface.test.ts b/src/mcp-public-surface.test.ts index d1c8dbe7..81435aad 100644 --- a/src/mcp-public-surface.test.ts +++ b/src/mcp-public-surface.test.ts @@ -26,6 +26,7 @@ import * as publicMcpClient from "../packages/mcp/src/client.js"; import { createMockCodeNavigationService, createMockGitHitsService, + createMockListService, createMockPackageIntelligenceService, createMockReadService, } from "./services/test-helpers.js"; @@ -48,6 +49,7 @@ function createServices( codeNavigationService: createMockCodeNavigationService(), githitsService: createMockGitHitsService(), packageIntelligenceService: createMockPackageIntelligenceService(), + listService: createMockListService(), readService: createMockReadService(), ...overrides, }; @@ -66,10 +68,9 @@ const EXPECTED_DESCRIPTOR_NAMES = [ "get_example", "search", "search_status", - "code_files", + "list", "read", "code_grep", - "docs_list", "pkg_info", "pkg_vulns", "pkg_deps", @@ -80,17 +81,16 @@ const EXPECTED_DESCRIPTOR_NAMES = [ const EXPECTED_SMOKE_NAMES = [ "quick_start", "get_example", + "search", + "search_status", + "list", + "read", + "code_grep", "pkg_info", - "pkg_deps", "pkg_vulns", + "pkg_deps", "pkg_changelog", "pkg_upgrade_review", - "docs_list", - "code_files", - "read", - "code_grep", - "search", - "search_status", ] as const; describe("public MCP package surface", () => { @@ -112,7 +112,8 @@ describe("public MCP package surface", () => { ); expect(names).toEqual([...EXPECTED_DESCRIPTOR_NAMES]); - expect(names).toHaveLength(13); + expect(names).toHaveLength(12); + expect(names).toContain("list"); expect(names).toContain("read"); expect(names).not.toContain("code_read"); expect(names).not.toContain("docs_read"); @@ -134,10 +135,14 @@ describe("public MCP package surface", () => { expect(inventory).not.toContain("ask"); expect(inventory).not.toContain("code_read"); expect(inventory).not.toContain("docs_read"); + expect(inventory).not.toContain("docs_list"); + expect(inventory).not.toContain("code_files"); } expect("createLocalMcpServer" in publicMcp).toBe(false); expect("ReadServiceImpl" in publicMcp).toBe(false); expect(publicMcpClient.ReadServiceImpl).toBeDefined(); + expect("ListServiceImpl" in publicMcp).toBe(false); + expect(publicMcpClient.ListServiceImpl).toBeDefined(); expect("AgenticAskServiceImpl" in publicMcpClient).toBe(false); }); @@ -377,8 +382,9 @@ describe("public MCP package surface", () => { const payload = JSON.parse(result.content[0]?.text ?? "{}") as { details?: { action?: string }; }; - expect(payload.details?.action).toContain("`code_files`"); - expect(payload.details?.action).toContain('path_prefix: "docs/"'); + expect(payload.details?.action).toContain('`list` with `paths: ["docs/"]`'); + expect(payload.details?.action).not.toContain("code_files"); + expect(payload.details?.action).not.toContain("path_prefix"); expect(payload.details?.action).toContain("`code_grep`"); expect(payload.details?.action).not.toContain("githits code"); }); diff --git a/src/shared/index.ts b/src/shared/index.ts index 035a3919..7d89291e 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -127,8 +127,6 @@ export { type ReadPackageDocRequestBuildResult, type ReadPackageDocRequestInput, renderGrepRepoText, - renderListFilesText, - renderListPackageDocsText, renderReadFileText, renderReadPackageDocText, renderUnifiedSearchError, diff --git a/src/tools/grep-repo-parity.test.ts b/src/tools/grep-repo-parity.test.ts index 52ccc305..cb3467b3 100644 --- a/src/tools/grep-repo-parity.test.ts +++ b/src/tools/grep-repo-parity.test.ts @@ -217,10 +217,15 @@ describe("grep_repo parity", () => { ...mcp, details: mcpDetails, }); - expect(cliAction).toContain("`githits code files`"); + expect(cliAction).toContain('`githits list "docs/"`'); expect(cliAction).toContain("`githits code grep`"); - expect(mcpAction).toContain("`code_files`"); + expect(mcpAction).toContain('`list` with `paths: ["docs/"]`'); expect(mcpAction).toContain("`code_grep`"); + expect(cliAction).not.toContain("paths:"); + expect(cliAction).not.toContain("code_files"); + expect(cliAction).not.toContain("path_prefix"); + expect(mcpAction).not.toContain("code_files"); + expect(mcpAction).not.toContain("path_prefix"); }); it.each([ @@ -285,10 +290,15 @@ describe("grep_repo parity", () => { graphqlCode: code, }, }); - expect(cliAction).toContain("`githits code files`"); + expect(cliAction).toContain('`githits list "bench/data/"`'); expect(cliAction).toContain("`githits code grep`"); - expect(mcpAction).toContain("`code_files`"); + expect(mcpAction).toContain('`list` with `paths: ["bench/data/"]`'); expect(mcpAction).toContain("`code_grep`"); + expect(cliAction).not.toContain("paths:"); + expect(cliAction).not.toContain("code_files"); + expect(cliAction).not.toContain("path_prefix"); + expect(mcpAction).not.toContain("code_files"); + expect(mcpAction).not.toContain("path_prefix"); }, ); @@ -308,9 +318,14 @@ describe("grep_repo parity", () => { expect(cliData).toEqual(mcpData); expect(cli.code).toBe("INVALID_ARGUMENT"); expect(cliError).toContain("``"); - expect(cliError).toContain("`githits code files`"); + expect(cliError).toContain("`githits list `"); expect(mcpError).toContain("`pattern`"); - expect(mcpError).toContain("`code_files`"); + expect(mcpError).toContain("`list`"); + expect(cliError).not.toContain("paths:"); + expect(cliError).not.toContain("code_files"); + expect(cliError).not.toContain("path_prefix"); + expect(mcpError).not.toContain("code_files"); + expect(mcpError).not.toContain("path_prefix"); }); }); diff --git a/src/tools/list-files-parity.test.ts b/src/tools/list-files-parity.test.ts deleted file mode 100644 index 4402c1ad..00000000 --- a/src/tools/list-files-parity.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -// PARITY TEST — enforces: -// PARITY-JSON-KEYS CLI --json output and MCP `format: "json"` payload -// parse to deepEqual JSON objects for equivalent -// inputs. The MCP default is `text`; this helper -// opts into JSON to compare like-for-like. -// PARITY-ERROR-ENVELOPE Both surfaces emit { error, code, retryable, details? }. - -import { describe, expect, it, mock, spyOn } from "bun:test"; -import { - CodeNavigationIndexingError, - CodeNavigationTargetNotFoundError, - type ListFilesResult, -} from "@githits/core-internal"; -import { - type PkgFilesCommandDependencies, - pkgFilesAction, -} from "../commands/code/files.js"; -import { - createMockCodeNavigationService, - defaultListFilesResult, -} from "../services/test-helpers.js"; -import { - createParityMcpTool, - isProcessExitSentinel, -} from "./parity-test-helpers.js"; - -function cliDeps( - overrides: Partial = {}, -): PkgFilesCommandDependencies { - return { - codeNavigationService: createMockCodeNavigationService(), - codeNavigationUrl: "https://pkgseer.dev", - hasValidToken: true, - mcpUrl: "https://mcp.example.com", - ...overrides, - }; -} - -async function cliJson( - spec: string | undefined, - pathPrefix: string | undefined, - options: Parameters[2] = {}, - deps: PkgFilesCommandDependencies = cliDeps(), -): Promise { - const logSpy = spyOn(console, "log").mockImplementation(() => {}); - const errSpy = spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - try { - try { - // In spec mode the CLI takes (spec, path-prefix) positionals; - // in repo-URL mode it takes (path-prefix, undefined). - const hasRepoUrl = Boolean(options.repoUrl); - const first = hasRepoUrl ? pathPrefix : spec; - const second = hasRepoUrl ? undefined : pathPrefix; - await pkgFilesAction(first, second, { ...options, json: true }, deps); - } catch (error) { - if (!isProcessExitSentinel(error)) throw error; - } - const raw = - (logSpy.mock.calls[0]?.[0] as string | undefined) ?? - (errSpy.mock.calls[0]?.[0] as string | undefined); - return raw ? JSON.parse(raw) : undefined; - } finally { - logSpy.mockRestore(); - errSpy.mockRestore(); - exitSpy.mockRestore(); - } -} - -interface McpArgs { - target: string; - path?: string; - path_prefix?: string; - globs?: string[]; - extensions?: string[]; - file_types?: string[]; - languages?: string[]; - file_intent?: string; - file_intents?: string[]; - exclude_file_intents?: string[]; - exclude_doc_files?: boolean; - exclude_test_files?: boolean; - include_hidden?: boolean; - limit?: number; - wait_timeout_ms?: number; - format?: "json" | "text"; -} - -async function mcpJson( - args: McpArgs, - listFilesMock?: () => Promise, -): Promise { - const service = createMockCodeNavigationService( - listFilesMock ? { listFiles: listFilesMock as never } : {}, - ); - const tool = createParityMcpTool("code_files", { - codeNavigationService: service, - }); - // Parity is asserted against the JSON envelope. The MCP default is - // text, so this helper opts into JSON to match the CLI `--json` - // payload shape. - const result = await tool.handler({ ...args, format: "json" }, {}); - return JSON.parse(result.content[0]?.text ?? ""); -} - -describe("list_files parity", () => { - it("PARITY-JSON-KEYS: happy package addressing CLI === MCP", async () => { - const fn = mock(() => Promise.resolve(defaultListFilesResult)); - const cli = await cliJson( - "npm:express", - undefined, - {}, - cliDeps({ - codeNavigationService: createMockCodeNavigationService({ - listFiles: fn as never, - }), - }), - ); - const mcp = await mcpJson( - { - target: "npm:express", - }, - fn as never, - ); - expect(cli).toEqual(mcp); - const envelope = cli as { registry: string; total: number }; - expect(envelope.registry).toBe("npm"); - expect(envelope.total).toBe(2); - }); - - it.each([ - "https://github.com/expressjs/express", - "https://codeberg.org/zigil/decimal", - "https://gitlab.com/group/subgroup/project", - ])( - "PARITY-JSON-KEYS: repo-URL addressing CLI === MCP %s", - async (repoUrl) => { - const fn = mock(() => Promise.resolve(defaultListFilesResult)); - const cli = await cliJson( - undefined, - undefined, - { - repoUrl: repoUrl, - gitRef: "main", - }, - cliDeps({ - codeNavigationService: createMockCodeNavigationService({ - listFiles: fn as never, - }), - }), - ); - const mcp = await mcpJson( - { - target: `${repoUrl}@main`, - }, - fn as never, - ); - expect(cli).toEqual(mcp); - }, - ); - - it("PARITY-JSON-KEYS: path_prefix echoes in filter block on both surfaces", async () => { - const fn = mock(() => Promise.resolve(defaultListFilesResult)); - const cli = await cliJson( - "npm:express", - "src/", - {}, - cliDeps({ - codeNavigationService: createMockCodeNavigationService({ - listFiles: fn as never, - }), - }), - ); - const mcp = await mcpJson( - { - target: "npm:express", - path_prefix: "src/", - }, - fn as never, - ); - expect(cli).toEqual(mcp); - expect( - (cli as { filter?: { pathPrefix?: string } }).filter?.pathPrefix, - ).toBe("src/"); - }); - - it("PARITY-JSON-KEYS: explicit limit echoes in filter block on both surfaces", async () => { - const fn = mock(() => Promise.resolve(defaultListFilesResult)); - const cli = await cliJson( - "npm:express", - undefined, - { limit: "50" }, - cliDeps({ - codeNavigationService: createMockCodeNavigationService({ - listFiles: fn as never, - }), - }), - ); - const mcp = await mcpJson( - { - target: "npm:express", - limit: 50, - }, - fn as never, - ); - expect(cli).toEqual(mcp); - expect((cli as { filter?: { limit?: number } }).filter?.limit).toBe(50); - }); - - it("PARITY-JSON-KEYS: advanced filters echo identically on both surfaces", async () => { - const fn = mock(() => Promise.resolve(defaultListFilesResult)); - const cli = await cliJson( - "npm:express", - "src/", - { - path: "README.md", - glob: ["test/**/*.js"], - ext: ["js"], - fileType: ["source"], - language: ["JavaScript"], - fileIntent: ["production", "test"], - excludeIntent: ["generated"], - excludeDocs: true, - excludeTests: false, - hidden: true, - }, - cliDeps({ - codeNavigationService: createMockCodeNavigationService({ - listFiles: fn as never, - }), - }), - ); - const mcp = await mcpJson( - { - target: "npm:express", - path: "README.md", - path_prefix: "src/", - globs: ["test/**/*.js"], - extensions: ["js"], - file_types: ["source"], - languages: ["JavaScript"], - file_intents: ["production", "test"], - exclude_file_intents: ["generated"], - exclude_doc_files: true, - exclude_test_files: false, - include_hidden: true, - }, - fn as never, - ); - expect(cli).toEqual(mcp); - }); - - it("PARITY-ERROR-ENVELOPE: INDEXING identical on both surfaces", async () => { - const fn = mock(() => - Promise.reject( - new CodeNavigationIndexingError("Target is indexing.", "ref_abc", [ - { version: "4.21.0", ref: "v4.21.0" }, - ]), - ), - ); - const cli = await cliJson( - "npm:express", - undefined, - {}, - cliDeps({ - codeNavigationService: createMockCodeNavigationService({ - listFiles: fn as never, - }), - }), - ); - const mcp = await mcpJson( - { - target: "npm:express", - }, - fn as never, - ); - expect(cli).toEqual(mcp); - expect((cli as { code: string; retryable: boolean }).code).toBe("INDEXING"); - expect((cli as { code: string; retryable: boolean }).retryable).toBe(true); - }); - - it("PARITY-ERROR-ENVELOPE: NOT_FOUND identical on both surfaces", async () => { - const fn = mock(() => - Promise.reject( - new CodeNavigationTargetNotFoundError("Package not found"), - ), - ); - const cli = await cliJson( - "npm:ghost", - undefined, - {}, - cliDeps({ - codeNavigationService: createMockCodeNavigationService({ - listFiles: fn as never, - }), - }), - ); - const mcp = await mcpJson( - { - target: "npm:ghost", - }, - fn as never, - ); - expect(cli).toEqual(mcp); - expect((cli as { code: string }).code).toBe("NOT_FOUND"); - }); - - it("PARITY-ERROR-ENVELOPE: INVALID_ARGUMENT on both surfaces carries `retryable: false` (full shape)", async () => { - const cli = await cliJson(undefined, undefined, {}); - const mcp = await mcpJson({ target: "" }); - // Assert the exact shape (including retryable) so future drift - // surfaces here rather than in a production agent's envelope. - // Message text differs by surface; that's acceptable. - expect(cli).toMatchObject({ - code: "INVALID_ARGUMENT", - retryable: false, - error: expect.any(String), - }); - expect(mcp).toMatchObject({ - code: "INVALID_ARGUMENT", - retryable: false, - error: expect.any(String), - }); - // Both surfaces must have the same set of keys. - expect(Object.keys(cli as object).sort()).toEqual( - Object.keys(mcp as object).sort(), - ); - }); -}); diff --git a/src/tools/list-package-docs-parity.test.ts b/src/tools/list-package-docs-parity.test.ts deleted file mode 100644 index 44cf932d..00000000 --- a/src/tools/list-package-docs-parity.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it, mock, spyOn } from "bun:test"; -import type { PackageIntelligenceService } from "@githits/core-internal"; -import { PackageIntelligenceTargetNotFoundError } from "@githits/core-internal"; -import { - type DocsListCommandDependencies, - docsListAction, -} from "../commands/docs/list.js"; -import { - createMockPackageIntelligenceService, - defaultPackageDocsList, -} from "../services/test-helpers.js"; -import { - createParityMcpTool, - isProcessExitSentinel, -} from "./parity-test-helpers.js"; - -function cliDeps( - overrides: Partial = {}, -): DocsListCommandDependencies { - return { - packageIntelligenceService: createMockPackageIntelligenceService(), - codeNavigationUrl: "https://pkgseer.dev", - hasValidToken: true, - mcpUrl: "https://mcp.example.com", - ...overrides, - }; -} - -async function cliJson( - spec: string, - deps: DocsListCommandDependencies = cliDeps(), -): Promise { - const logSpy = spyOn(console, "log").mockImplementation(() => {}); - const errSpy = spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - try { - try { - await docsListAction(spec, { json: true }, deps); - } catch (error) { - if (!isProcessExitSentinel(error)) throw error; - } - const raw = - (logSpy.mock.calls[0]?.[0] as string | undefined) ?? - (errSpy.mock.calls[0]?.[0] as string | undefined); - return raw ? JSON.parse(raw) : undefined; - } finally { - logSpy.mockRestore(); - errSpy.mockRestore(); - exitSpy.mockRestore(); - } -} - -async function mcpJson( - args: { target: string }, - listPackageDocsMock?: () => Promise, -): Promise { - const service = createMockPackageIntelligenceService( - listPackageDocsMock - ? { listPackageDocs: listPackageDocsMock as never } - : {}, - ); - const tool = createParityMcpTool("docs_list", { - packageIntelligenceService: service, - }); - const result = await tool.handler({ ...args, format: "json" }, {}); - return JSON.parse(result.content[0]?.text ?? ""); -} - -describe("list_package_docs parity", () => { - it("PARITY-JSON-KEYS: happy path CLI === MCP", async () => { - const listPackageDocs = mock( - ( - _params?: Parameters[0], - ) => Promise.resolve(defaultPackageDocsList), - ); - const cli = await cliJson( - "npm:express@5.2.1", - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - listPackageDocs: listPackageDocs as never, - }), - }), - ); - const mcp = await mcpJson( - { target: "npm:express@5.2.1" }, - listPackageDocs as never, - ); - expect(listPackageDocs).toHaveBeenCalledTimes(2); - expect(listPackageDocs.mock.calls).toEqual([ - [{ registry: "NPM", packageName: "express", version: "5.2.1" }], - [{ registry: "NPM", packageName: "express", version: "5.2.1" }], - ]); - expect(cli).toEqual(mcp); - }); - - it("PARITY-ERROR-ENVELOPE: NOT_FOUND CLI === MCP", async () => { - const fn = mock(() => - Promise.reject( - new PackageIntelligenceTargetNotFoundError("Package not found"), - ), - ); - const cli = await cliJson( - "npm:ghost", - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - listPackageDocs: fn as never, - }), - }), - ); - const mcp = await mcpJson({ target: "npm:ghost" }, fn as never); - expect(cli).toEqual(mcp); - expect(cli).toEqual({ - error: "Package not found", - code: "NOT_FOUND", - retryable: false, - }); - }); - - it("PARITY-JSON-KEYS: empty list CLI === MCP", async () => { - const fn = mock(() => - Promise.resolve({ - ...defaultPackageDocsList, - pages: [], - pageInfo: { hasNextPage: false, totalCount: 0 }, - }), - ); - const cli = await cliJson( - "npm:express", - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - listPackageDocs: fn as never, - }), - }), - ); - const mcp = await mcpJson({ target: "npm:express" }, fn as never); - expect(cli).toEqual(mcp); - }); - - it("PARITY-JSON-KEYS: active empty list CLI === MCP", async () => { - const fn = mock(() => - Promise.resolve({ - ...defaultPackageDocsList, - codeIndexState: "INDEXING", - pages: [], - pageInfo: { hasNextPage: false, totalCount: 0 }, - }), - ); - const cli = await cliJson( - "npm:express", - cliDeps({ - packageIntelligenceService: createMockPackageIntelligenceService({ - listPackageDocs: fn as never, - }), - }), - ); - const mcp = await mcpJson({ target: "npm:express" }, fn as never); - expect(cli).toEqual(mcp); - expect(cli).toMatchObject({ codeIndexState: "INDEXING", pages: [] }); - }); -}); diff --git a/src/tools/parity-test-helpers.ts b/src/tools/parity-test-helpers.ts index a075de0a..5407f544 100644 --- a/src/tools/parity-test-helpers.ts +++ b/src/tools/parity-test-helpers.ts @@ -14,6 +14,7 @@ import { import { createMockCodeNavigationService, createMockGitHitsService, + createMockListService, createMockPackageIntelligenceService, createMockReadService, createMockResolveTargetService, @@ -39,6 +40,7 @@ export function createParityMcpTool( codeNavigationService: createMockCodeNavigationService(), githitsService: createMockGitHitsService(), packageIntelligenceService: createMockPackageIntelligenceService(), + listService: createMockListService(), readService: createMockReadService(), ...overrides, }; @@ -63,6 +65,7 @@ export function createParityExperimentalMcpTool< codeNavigationService: createMockCodeNavigationService(), githitsService: createMockGitHitsService(), packageIntelligenceService: createMockPackageIntelligenceService(), + listService: createMockListService(), readService: createMockReadService(), resolveTargetService: createMockResolveTargetService(), ...overrides, diff --git a/src/tools/read-file-parity.test.ts b/src/tools/read-file-parity.test.ts index 3237efe1..ccb5c9a6 100644 --- a/src/tools/read-file-parity.test.ts +++ b/src/tools/read-file-parity.test.ts @@ -260,12 +260,15 @@ describe("read_file parity", () => { details: mcpDetails, }); expect(cliEnvelope.code).toBe("FILE_NOT_FOUND"); - expect(cliAction).toContain("`githits code files`"); + expect(cliAction).toContain("`githits list `"); expect(cliAction).toContain("`githits read`"); - expect(cliAction).toContain("without a path prefix"); - expect(mcpAction).toContain("`code_files`"); + expect(cliAction).not.toContain("paths:"); + expect(cliAction).not.toContain("code_files"); + expect(cliAction).not.toContain("path_prefix"); + expect(mcpAction).toContain("`list` without `paths`"); expect(mcpAction).toContain("`read`"); - expect(mcpAction).toContain("without `path_prefix`"); + expect(mcpAction).not.toContain("code_files"); + expect(mcpAction).not.toContain("path_prefix"); }); it("PARITY-ERROR-ENVELOPE: INDEXING identical on both surfaces", async () => { diff --git a/src/tools/repository-target-parity.test.ts b/src/tools/repository-target-parity.test.ts index 0b5f3d50..d83f58cd 100644 --- a/src/tools/repository-target-parity.test.ts +++ b/src/tools/repository-target-parity.test.ts @@ -14,7 +14,6 @@ import { createMockCodeNavigationService, createMockReadService, defaultGrepRepoResult, - defaultListFilesResult, defaultReadFileResult, defaultUnifiedSearchOutcome, } from "../services/test-helpers.js"; @@ -60,8 +59,7 @@ describe("provider target consumer parity", () => { expect(cli.params.to).toBe("release/v2@stable"); }); - it(`${compact} routes code navigation and read through their target boundaries`, async () => { - const listFiles = mock(() => Promise.resolve(defaultListFilesResult)); + it(`${compact} routes grep and read through their target boundaries`, async () => { const grepRepo = mock(() => Promise.resolve(defaultGrepRepoResult)); const read = mock( (): Promise => @@ -69,16 +67,11 @@ describe("provider target consumer parity", () => { ); const deps = { codeNavigationService: createMockCodeNavigationService({ - listFiles, grepRepo, }), readService: createMockReadService({ read }), }; - const codeFilesResult = await createParityMcpTool( - "code_files", - deps, - ).handler({ target: `${compact}@release/v1@stable` }, {}); const codeGrepResult = await createParityMcpTool( "code_grep", deps, @@ -98,15 +91,8 @@ describe("provider target consumer parity", () => { {}, ); - expect(codeFilesResult.isError).toBeUndefined(); expect(codeGrepResult.isError).toBeUndefined(); expect(readResult.isError).toBeUndefined(); - expect(listFiles).toHaveBeenCalledTimes(1); - expect(listFiles).toHaveBeenCalledWith( - expect.objectContaining({ - target: { repoUrl, gitRef: "release/v1@stable" }, - }), - ); expect(grepRepo).toHaveBeenCalledTimes(1); expect(grepRepo).toHaveBeenCalledWith( expect.objectContaining({