From 97e946bfae19a797b91a25bca6d82d907a1c9909 Mon Sep 17 00:00:00 2001 From: Vladimir Krivosheev Date: Sat, 31 Jan 2026 10:34:03 +0100 Subject: [PATCH] (mcp-search) IJPL-224042 unified search (take 2 - 4 tools instead of 1) GitOrigin-RevId: 4080c7be42b8e32245f1a4eabd0135fd6f2f6234 --- .ai/guide.md | 15 +- AGENTS.md | 8 +- build/mcp-servers/ij-proxy/README.md | 43 +- build/mcp-servers/ij-proxy/bun.lock | 10 +- .../claude-codex-ij-proxy-overview.md | 10 +- .../ij-proxy/dist/ij-mcp-proxy.mjs | 2301 +++++++++++++---- build/mcp-servers/ij-proxy/ij-mcp-proxy.ts | 154 +- .../jb-mcp-proxy-fallback.test.ts | 80 + .../jb-mcp-search.tool.test.ts | 21 +- .../integration-tests/reconnect.test.ts | 45 + .../integration-tests/tool-list.test.ts | 68 +- .../integration-tests/workarounds.test.ts | 31 + build/mcp-servers/ij-proxy/package.json | 6 +- .../proxy-tools/handlers/find.test.ts | 109 - .../ij-proxy/proxy-tools/handlers/find.ts | 158 -- .../proxy-tools/handlers/read.edge.test.ts | 26 +- .../proxy-tools/handlers/read.test.ts | 56 +- .../ij-proxy/proxy-tools/handlers/read.ts | 48 +- .../proxy-tools/handlers/search-constants.ts | 5 + .../proxy-tools/handlers/search-file.ts | 99 + .../handlers/search-file.unit.test.ts | 101 + .../proxy-tools/handlers/search-scope.ts | 281 ++ .../proxy-tools/handlers/search-shared.ts | 123 + .../proxy-tools/handlers/search-symbol.ts | 30 + .../proxy-tools/handlers/search-text.ts | 122 + .../handlers/search-text.unit.test.ts | 126 + .../proxy-tools/handlers/search.test.ts | 240 +- .../ij-proxy/proxy-tools/handlers/search.ts | 494 +--- .../proxy-tools/handlers/test-helpers.ts | 6 + .../ij-proxy/proxy-tools/registry.ts | 125 +- .../ij-proxy/proxy-tools/schemas.ts | 102 +- .../ij-proxy/proxy-tools/shared.ts | 149 +- .../ij-proxy/proxy-tools/tooling.ts | 89 +- .../mcp-servers/ij-proxy/proxy-tools/types.ts | 18 +- build/mcp-servers/ij-proxy/search.md | 110 + .../mcp-servers/ij-proxy/stream-transport.ts | 24 +- build/mcp-servers/ij-proxy/test-utils.ts | 45 +- build/mcp-servers/ij-proxy/workarounds.ts | 23 +- 38 files changed, 3723 insertions(+), 1778 deletions(-) create mode 100644 build/mcp-servers/ij-proxy/integration-tests/jb-mcp-proxy-fallback.test.ts create mode 100644 build/mcp-servers/ij-proxy/integration-tests/reconnect.test.ts create mode 100644 build/mcp-servers/ij-proxy/integration-tests/workarounds.test.ts delete mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/find.test.ts delete mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/find.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-constants.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.unit.test.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-scope.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-shared.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-symbol.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.ts create mode 100644 build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.unit.test.ts create mode 100644 build/mcp-servers/ij-proxy/search.md diff --git a/.ai/guide.md b/.ai/guide.md index e14849d939ca..e39272fccfd5 100644 --- a/.ai/guide.md +++ b/.ai/guide.md @@ -47,14 +47,20 @@ Preserve IDE-serialized .iml files in canonical form. Do not: - Read: `mcp__ijproxy__read_file` - Edit/Write: `mcp__ijproxy__apply_patch` -- Search: `mcp__ijproxy__search` (use `providers=["files"]` for filenames; `mode=lexical` + `providers=["text"]` for contents; `providers=["symbols"]`/`["classes"]` for symbols) +- **Search symbols (preferred):** `mcp__ijproxy__search_symbol` +- Find files (glob): `mcp__ijproxy__search_file` +- Search text: `mcp__ijproxy__search_text` +- Search regex: `mcp__ijproxy__search_regex` - List dir: `mcp__ijproxy__list_dir` - Read: `read` - Edit: `edit` - Write: `write` -- Search: `search` (use `providers=["files"]` for filenames; `mode=lexical` + `providers=["text"]` for contents; `providers=["symbols"]`/`["classes"]` for symbols) +- **Search symbols (preferred):** `search_symbol` +- Find files (glob): `search_file` +- Search text: `search_text` +- Search regex: `search_regex` - List dir: `list_dir` @@ -72,15 +78,16 @@ Direct JetBrains MCP connection. Use when ijproxy unavailable. ### Client fallback (no MCP) -- Use `./community/tools/fd.cmd` instead of Glob and `./community/tools/rg.cmd` instead of Grep. These are the only allowed shell file ops on repo paths. +- **No MCP:** use `./community/tools/fd.cmd` (file search) and `./community/tools/rg.cmd` (text/regex search). These are the only allowed shell file ops on repo paths. -- Use `./tools/fd.cmd` instead of Glob and `./tools/rg.cmd` instead of Grep. These are the only allowed shell file ops on repo paths. +- **No MCP:** use `./tools/fd.cmd` (file search) and `./tools/rg.cmd` (text/regex search). These are the only allowed shell file ops on repo paths. ### IDE-backed semantic tools Available via ijproxy or JetBrains MCP. Use these for semantic operations; avoid manual search/replace when a refactor exists. +- **Default to `search_symbol` (if available) for classes/methods/fields; use `search_text`/`search_regex` mainly for strings, comments, and non-symbol matches.** - Inspections & symbol info: `get_file_problems`, `get_symbol_info` - Refactors: `rename` (ijproxy) / `rename_refactoring` (JetBrains MCP); use for renames and avoid manual search/replace. - Formatting: `reformat_file` diff --git a/AGENTS.md b/AGENTS.md index 9c95ede2561a..690ca24adb3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,10 @@ Preserve IDE-serialized .iml files in canonical form. Do not: - Read: `mcp__ijproxy__read_file` - Edit/Write: `mcp__ijproxy__apply_patch` -- Search: `mcp__ijproxy__search` (use `providers=["files"]` for filenames; `mode=lexical` + `providers=["text"]` for contents; `providers=["symbols"]`/`["classes"]` for symbols) +- **Search symbols (preferred):** `mcp__ijproxy__search_symbol` +- Find files (glob): `mcp__ijproxy__search_file` +- Search text: `mcp__ijproxy__search_text` +- Search regex: `mcp__ijproxy__search_regex` - List dir: `mcp__ijproxy__list_dir` ### jetbrains MCP (fallback) @@ -83,11 +86,12 @@ Direct JetBrains MCP connection. Use when ijproxy unavailable. ### Client fallback (no MCP) -- Use `./tools/fd.cmd` instead of Glob and `./tools/rg.cmd` instead of Grep. These are the only allowed shell file ops on repo paths. +- **No MCP:** use `./tools/fd.cmd` (file search) and `./tools/rg.cmd` (text/regex search). These are the only allowed shell file ops on repo paths. ### IDE-backed semantic tools Available via ijproxy or JetBrains MCP. Use these for semantic operations; avoid manual search/replace when a refactor exists. +- **Default to `search_symbol` (if available) for classes/methods/fields; use `search_text`/`search_regex` mainly for strings, comments, and non-symbol matches.** - Inspections & symbol info: `get_file_problems`, `get_symbol_info` - Refactors: `rename` (ijproxy) / `rename_refactoring` (JetBrains MCP); use for renames and avoid manual search/replace. - Formatting: `reformat_file` diff --git a/build/mcp-servers/ij-proxy/README.md b/build/mcp-servers/ij-proxy/README.md index 7610bedd7808..f088887cb3af 100644 --- a/build/mcp-servers/ij-proxy/README.md +++ b/build/mcp-servers/ij-proxy/README.md @@ -48,24 +48,25 @@ Environment variables (optional): - `JETBRAINS_MCP_QUEUE_WAIT_TIMEOUT_S`: timeout for upstream tool calls waiting to be sent while the stream is unavailable (seconds). Defaults to the tool-call timeout when set; use `0` to disable. - `JETBRAINS_MCP_PROJECT_PATH`: override the injected project path (defaults to `process.cwd()`, relative paths resolve from the current working directory). - `MCP_LOG`: path to a log file for proxy progress (cleared on startup). -- `JETBRAINS_MCP_TOOL_MODE`: tool API shape to expose. `codex` (default) uses `read_file`, `search`, `list_dir`, `apply_patch`, `rename`. `cc` uses `read`, `write`, `edit`, `search`, `rename`. -- `JETBRAINS_MCP_SEARCH_TOOL`: search capability mode. `auto` (default) uses upstream Search Everywhere when available; otherwise falls back to legacy text/file search. `search` forces Search Everywhere features when available (falls back to legacy with a warning). `legacy` (or `grep`/`false`/`0`) disables Search Everywhere features and exposes only text/file search capabilities. +- `JETBRAINS_MCP_TOOL_MODE`: tool API shape to expose. `codex` (default) uses `read_file`, `list_dir`, `apply_patch`, `rename`. `cc` uses `read`, `write`, `edit`, `rename`. Search tools are documented in `search.md`. +- `JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH`: force legacy search tools when available; hides search_text/search_regex/search_file if only the new tools exist. - `JETBRAINS_MCP_PROXY_DISABLE_WORKAROUNDS`: disable all version-gated workarounds (set to any non-empty value except `0` or `false`). - `JETBRAINS_MCP_PROXY_DISABLE_WORKAROUND_KEYS`: comma-separated list of workaround keys to disable (see `workarounds.ts`). - `JETBRAINS_MCP_PROXY_WORKAROUND_DEBUG`: emit debug logs when workarounds are skipped or disabled (set to any non-empty value except `0` or `false`). ## Tool variants -The proxy is not a pure pass-through: it always exposes a mode-specific proxy tool set, filters out blocked tools (for example `create_new_file` and `execute_terminal_command`), hides upstream tools that are replaced by proxy tools, and keeps the remaining upstream tools whose names do not collide with proxy tools. +The proxy is not a pure pass-through: it exposes a mode-specific proxy tool set (unless the upstream already provides the same tool name), filters out blocked tools (for example `create_new_file` and `execute_terminal_command`), hides upstream tools that are replaced by proxy tools, and keeps the remaining upstream tools whose names do not collide with proxy tools. -| Mode | Proxy tools (always exposed) | Upstream tools also exposed | -|-------|-------------------------------------------------------------------|-------------------------------------------------------------------------------| -| codex | `read_file`, `search`, `list_dir`, `apply_patch`, `rename` | All upstream tools except blocked names, replaced tools, and name collisions. | -| cc | `read`, `write`, `edit`, `search`, `rename` | All upstream tools except blocked names, replaced tools, and name collisions. | +| Mode | Proxy tools (exposed when not provided upstream) | Upstream tools also exposed | +|-------|--------------------------------------------------------------|-------------------------------------------------------------------------------| +| codex | `read_file`, `list_dir`, `apply_patch`, `rename` | All upstream tools except blocked names, replaced tools, and name collisions. | +| cc | `read`, `write`, `edit`, `rename` | All upstream tools except blocked names, replaced tools, and name collisions. | Notes: -- Upstream JetBrains file tools that are replaced by proxy tools (for example `get_file_text_by_path`, `replace_text_in_file`, `find_files_by_name_keyword`, `find_files_by_glob`, `search_in_files_*`, `list_directory_tree`) are hidden. -- Upstream `search` is wrapped by the proxy `search` tool. +- Upstream JetBrains file tools that are replaced by proxy tools (for example `get_file_text_by_path`, `replace_text_in_file`, `list_directory_tree`) are hidden. +- If the upstream server exposes `read_file` or `search_*`, ij-proxy passes them through unchanged and does not expose proxy shims for those names. +- Search tools and their compatibility are documented in `search.md`. - Use `apply_patch` (codex) or `write` (cc) to create files. ## Custom tool commands (name + behavior mapping) @@ -75,25 +76,23 @@ The proxy exposes a small, client-shaped tool set. Names are chosen to match cli - **codex** mode mirrors the Codex CLI tool surface (see `/Users/develar/Downloads/codex-main`). - **cc** mode mirrors the Claude Code tool surface (see `cc-tools.json`). -Each proxy command maps to one or more JetBrains MCP tools: +Each proxy command maps to one or more JetBrains MCP tools. Search tool mapping and compatibility are documented in `search.md`. ### codex mode -| Proxy command | Why this name | JetBrains MCP under the hood | -|---------------|----------------------------------------------------------------------|-------------------------------------------------------------------------------------| -| `read_file` | Matches Codex `read_file` (line-numbered output + indentation mode). | `get_file_text_by_path` | -| `search` | Unified search for symbols (when available), files, or text. | `search` (if available), `search_in_files_*`, `find_files_by_*` | -| `list_dir` | Matches Codex `list_dir`. | `list_directory_tree` | -| `apply_patch` | Matches Codex `apply_patch`. | `get_file_text_by_path` + `create_new_file`; uses `git rm`/`git mv` for delete/move | +| Proxy command | Why this name | JetBrains MCP under the hood | +|-----------------|----------------------------------------------------------------------|-------------------------------------------------------------------------------------| +| `read_file` | Matches Codex `read_file` (line-numbered output + indentation mode). | `get_file_text_by_path` | +| `list_dir` | Matches Codex `list_dir`. | `list_directory_tree` | +| `apply_patch` | Matches Codex `apply_patch`. | `get_file_text_by_path` + `create_new_file`; uses `git rm`/`git mv` for delete/move | ### cc mode -| Proxy command | Why this name | JetBrains MCP under the hood | -|---------------|---------------------------------------------|-----------------------------------------------------------------| -| `read` | Matches Claude Code `read` (raw text). | `get_file_text_by_path` | -| `write` | Matches Claude Code `write`. | `create_new_file` (overwrite) | -| `edit` | Matches Claude Code `edit`. | `get_file_text_by_path` + `create_new_file` | -| `search` | Unified search for symbols (when available), files, or text. | `search` (if available), `search_in_files_*`, `find_files_by_*` | +| Proxy command | Why this name | JetBrains MCP under the hood | +|-----------------|----------------------------------------------|----------------------------------------------| +| `read` | Matches Claude Code `read` (raw text). | `get_file_text_by_path` | +| `write` | Matches Claude Code `write`. | `create_new_file` (overwrite) | +| `edit` | Matches Claude Code `edit`. | `get_file_text_by_path` + `create_new_file` | Example `.mcp.toml` entry (Codex): diff --git a/build/mcp-servers/ij-proxy/bun.lock b/build/mcp-servers/ij-proxy/bun.lock index 2a01c767a1ad..630a3acd1278 100644 --- a/build/mcp-servers/ij-proxy/bun.lock +++ b/build/mcp-servers/ij-proxy/bun.lock @@ -5,27 +5,23 @@ "": { "name": "ij-mcp-proxy", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", "@modelcontextprotocol/sdk": "^1.25.3", "is-port-reachable": "^4.0.0", "p-retry": "^7.1.1", - "p-timeout": "^7.0.1", "picomatch": "^4.0.3", "zod": "^4.3.6", }, "devDependencies": { - "@types/node": "^25.0.10", + "@types/node": "^25.1.0", }, }, }, "packages": { - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], - "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -151,8 +147,6 @@ "p-retry": ["p-retry@7.1.1", "", { "dependencies": { "is-network-error": "^1.1.0" } }, "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w=="], - "p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], diff --git a/build/mcp-servers/ij-proxy/claude-codex-ij-proxy-overview.md b/build/mcp-servers/ij-proxy/claude-codex-ij-proxy-overview.md index b5ed231e1560..b9b8381572a6 100644 --- a/build/mcp-servers/ij-proxy/claude-codex-ij-proxy-overview.md +++ b/build/mcp-servers/ij-proxy/claude-codex-ij-proxy-overview.md @@ -10,7 +10,7 @@ This note compares the file read/search/edit tool surfaces for: Note: The Claude Code tool list was captured on January 21, 2026 (see cc-tools.json). It can drift from current Claude Code builds. Note: The proxy tool list is mode-specific (`JETBRAINS_MCP_TOOL_MODE`, default: `codex`). -Note: The proxy search capabilities are configurable (`JETBRAINS_MCP_SEARCH_TOOL`, default: `auto`). +Note: The proxy exposes search shims only when upstream does not provide the same `search_*` tools; otherwise it passes the upstream tools through. ## Comparison table @@ -20,8 +20,8 @@ Note: The proxy search capabilities are configurable (`JETBRAINS_MCP_SEARCH_TOOL | Read | Read abs path; offset/limit | read_file abs + indentation | get_file_text_by_path (pathInProject) | codex: read_file (numbered); cc: read (raw) | | Indentation | No | Yes | No | codex only | | Dir listing | None in capture | list_dir | list_directory_tree | codex: list_dir; cc: none | -| File discovery | Glob | None (use list_dir) | find_files_by_glob/name | search (target=file) | -| Search output | Grep (content/paths) | grep (paths) | search_in_files_* (entries) | search (unified; uses upstream search when available, otherwise legacy text/file) +| File discovery | Glob | None (use list_dir) | find_files_by_glob/name | cc: glob; codex: find | +| Search output | Grep (content/paths) | grep (paths) | search_in_files_* (entries) | search_text/search_regex/search_file/search_symbol (when available) | | Edit/write | Edit/Write (no MultiEdit in capture) | apply_patch | replace_text_in_file + create_new_file | codex: apply_patch; cc: edit/write | | Path model | Absolute paths | Abs for read/list; cwd for apply_patch | Project-relative | Abs or project-relative | | apply_patch | No | Yes | No | codex: yes; cc: no | @@ -29,9 +29,9 @@ Note: The proxy search capabilities are configurable (`JETBRAINS_MCP_SEARCH_TOOL ## Key differences -- The proxy is not a pure pass-through: it always exposes a mode-specific proxy tool set, hides upstream tools replaced by proxy tools, and keeps the remaining upstream tools that do not collide with proxy tool names (blocked tools are filtered). +- The proxy is not a pure pass-through: it exposes a mode-specific proxy tool set unless the upstream already provides the same tool name, hides upstream tools replaced by proxy tools, and keeps the remaining upstream tools that do not collide with proxy tool names (blocked tools are filtered). - Upstream JetBrains MCP uses project-relative paths and structured search entries; the proxy returns plain text outputs. -- The proxy exposes a single `search` tool that routes to Search Everywhere when available and falls back to legacy text/file search otherwise. +- The proxy exposes `search_*` shims only when upstream does not provide the same tools; otherwise it passes upstream search tools through unchanged. - Codex relies on apply_patch for edits; Claude Code uses string replacement, and the proxy follows the selected mode. - Indentation-aware reads are Codex-style and only exposed in codex mode. diff --git a/build/mcp-servers/ij-proxy/dist/ij-mcp-proxy.mjs b/build/mcp-servers/ij-proxy/dist/ij-mcp-proxy.mjs index 411f061682e8..b3f4798a9c4a 100644 --- a/build/mcp-servers/ij-proxy/dist/ij-mcp-proxy.mjs +++ b/build/mcp-servers/ij-proxy/dist/ij-mcp-proxy.mjs @@ -5469,6 +5469,1067 @@ var require_dist = __commonJS((exports, module) => { exports.default = formatsPlugin; }); +// node_modules/picomatch/lib/constants.js +var require_constants = __commonJS((exports, module) => { + var POSIX_CHARS = { + DOT_LITERAL: "\\.", + PLUS_LITERAL: "\\+", + QMARK_LITERAL: "\\?", + SLASH_LITERAL: "\\/", + ONE_CHAR: "(?=.)", + QMARK: "[^/]", + END_ANCHOR: "(?:\\/|$)", + DOTS_SLASH: "\\.{1,2}(?:\\/|$)", + NO_DOT: "(?!\\.)", + NO_DOTS: "(?!(?:^|\\/)\\.{1,2}(?:\\/|$))", + NO_DOT_SLASH: "(?!\\.{0,1}(?:\\/|$))", + NO_DOTS_SLASH: "(?!\\.{1,2}(?:\\/|$))", + QMARK_NO_DOT: "[^.\\/]", + STAR: "[^/]*?", + START_ANCHOR: "(?:^|\\/)", + SEP: "/" + }, WINDOWS_CHARS = { + ...POSIX_CHARS, + SLASH_LITERAL: "[\\\\/]", + QMARK: "[^\\\\/]", + STAR: "[^\\\\/]*?", + DOTS_SLASH: "\\.{1,2}(?:[\\\\/]|$)", + NO_DOT: "(?!\\.)", + NO_DOTS: "(?!(?:^|[\\\\/])\\.{1,2}(?:[\\\\/]|$))", + NO_DOT_SLASH: "(?!\\.{0,1}(?:[\\\\/]|$))", + NO_DOTS_SLASH: "(?!\\.{1,2}(?:[\\\\/]|$))", + QMARK_NO_DOT: "[^.\\\\/]", + START_ANCHOR: "(?:^|[\\\\/])", + END_ANCHOR: "(?:[\\\\/]|$)", + SEP: "\\" + }, POSIX_REGEX_SOURCE = { + alnum: "a-zA-Z0-9", + alpha: "a-zA-Z", + ascii: "\\x00-\\x7F", + blank: " \\t", + cntrl: "\\x00-\\x1F\\x7F", + digit: "0-9", + graph: "\\x21-\\x7E", + lower: "a-z", + print: "\\x20-\\x7E ", + punct: "\\-!\"#$%&'()\\*+,./:;<=>?@[\\]^_`{|}~", + space: " \\t\\r\\n\\v\\f", + upper: "A-Z", + word: "A-Za-z0-9_", + xdigit: "A-Fa-f0-9" + }; + module.exports = { + MAX_LENGTH: 65536, + POSIX_REGEX_SOURCE, + REGEX_BACKSLASH: /\\(?![*+?^${}(|)[\]])/g, + REGEX_NON_SPECIAL_CHARS: /^[^@![\].,$*+?^{}()|\\/]+/, + REGEX_SPECIAL_CHARS: /[-*+?.^${}(|)[\]]/, + REGEX_SPECIAL_CHARS_BACKREF: /(\\?)((\W)(\3*))/g, + REGEX_SPECIAL_CHARS_GLOBAL: /([-*+?.^${}(|)[\]])/g, + REGEX_REMOVE_BACKSLASH: /(?:\[.*?[^\\]\]|\\(?=.))/g, + REPLACEMENTS: { + __proto__: null, + "***": "*", + "**/**": "**", + "**/**/**": "**" + }, + CHAR_0: 48, + CHAR_9: 57, + CHAR_UPPERCASE_A: 65, + CHAR_LOWERCASE_A: 97, + CHAR_UPPERCASE_Z: 90, + CHAR_LOWERCASE_Z: 122, + CHAR_LEFT_PARENTHESES: 40, + CHAR_RIGHT_PARENTHESES: 41, + CHAR_ASTERISK: 42, + CHAR_AMPERSAND: 38, + CHAR_AT: 64, + CHAR_BACKWARD_SLASH: 92, + CHAR_CARRIAGE_RETURN: 13, + CHAR_CIRCUMFLEX_ACCENT: 94, + CHAR_COLON: 58, + CHAR_COMMA: 44, + CHAR_DOT: 46, + CHAR_DOUBLE_QUOTE: 34, + CHAR_EQUAL: 61, + CHAR_EXCLAMATION_MARK: 33, + CHAR_FORM_FEED: 12, + CHAR_FORWARD_SLASH: 47, + CHAR_GRAVE_ACCENT: 96, + CHAR_HASH: 35, + CHAR_HYPHEN_MINUS: 45, + CHAR_LEFT_ANGLE_BRACKET: 60, + CHAR_LEFT_CURLY_BRACE: 123, + CHAR_LEFT_SQUARE_BRACKET: 91, + CHAR_LINE_FEED: 10, + CHAR_NO_BREAK_SPACE: 160, + CHAR_PERCENT: 37, + CHAR_PLUS: 43, + CHAR_QUESTION_MARK: 63, + CHAR_RIGHT_ANGLE_BRACKET: 62, + CHAR_RIGHT_CURLY_BRACE: 125, + CHAR_RIGHT_SQUARE_BRACKET: 93, + CHAR_SEMICOLON: 59, + CHAR_SINGLE_QUOTE: 39, + CHAR_SPACE: 32, + CHAR_TAB: 9, + CHAR_UNDERSCORE: 95, + CHAR_VERTICAL_LINE: 124, + CHAR_ZERO_WIDTH_NOBREAK_SPACE: 65279, + extglobChars(chars) { + return { + "!": { type: "negate", open: "(?:(?!(?:", close: `))${chars.STAR})` }, + "?": { type: "qmark", open: "(?:", close: ")?" }, + "+": { type: "plus", open: "(?:", close: ")+" }, + "*": { type: "star", open: "(?:", close: ")*" }, + "@": { type: "at", open: "(?:", close: ")" } + }; + }, + globChars(win32) { + return win32 === !0 ? WINDOWS_CHARS : POSIX_CHARS; + } + }; +}); + +// node_modules/picomatch/lib/utils.js +var require_utils2 = __commonJS((exports) => { + var { + REGEX_BACKSLASH, + REGEX_REMOVE_BACKSLASH, + REGEX_SPECIAL_CHARS, + REGEX_SPECIAL_CHARS_GLOBAL + } = require_constants(); + exports.isObject = (val) => val !== null && typeof val === "object" && !Array.isArray(val); + exports.hasRegexChars = (str) => REGEX_SPECIAL_CHARS.test(str); + exports.isRegexChar = (str) => str.length === 1 && exports.hasRegexChars(str); + exports.escapeRegex = (str) => str.replace(REGEX_SPECIAL_CHARS_GLOBAL, "\\$1"); + exports.toPosixSlashes = (str) => str.replace(REGEX_BACKSLASH, "/"); + exports.isWindows = () => { + if (typeof navigator < "u" && navigator.platform) { + let platform = navigator.platform.toLowerCase(); + return platform === "win32" || platform === "windows"; + } + if (typeof process < "u" && process.platform) + return process.platform === "win32"; + return !1; + }; + exports.removeBackslashes = (str) => { + return str.replace(REGEX_REMOVE_BACKSLASH, (match) => { + return match === "\\" ? "" : match; + }); + }; + exports.escapeLast = (input, char, lastIdx) => { + let idx = input.lastIndexOf(char, lastIdx); + if (idx === -1) + return input; + if (input[idx - 1] === "\\") + return exports.escapeLast(input, char, idx - 1); + return `${input.slice(0, idx)}\\${input.slice(idx)}`; + }; + exports.removePrefix = (input, state = {}) => { + let output = input; + if (output.startsWith("./")) + output = output.slice(2), state.prefix = "./"; + return output; + }; + exports.wrapOutput = (input, state = {}, options = {}) => { + let prepend = options.contains ? "" : "^", append = options.contains ? "" : "$", output = `${prepend}(?:${input})${append}`; + if (state.negated === !0) + output = `(?:^(?!${output}).*$)`; + return output; + }; + exports.basename = (path7, { windows } = {}) => { + let segs = path7.split(windows ? /[\\/]/ : "/"), last = segs[segs.length - 1]; + if (last === "") + return segs[segs.length - 2]; + return last; + }; +}); + +// node_modules/picomatch/lib/scan.js +var require_scan = __commonJS((exports, module) => { + var utils = require_utils2(), { + CHAR_ASTERISK, + CHAR_AT, + CHAR_BACKWARD_SLASH, + CHAR_COMMA, + CHAR_DOT, + CHAR_EXCLAMATION_MARK, + CHAR_FORWARD_SLASH, + CHAR_LEFT_CURLY_BRACE, + CHAR_LEFT_PARENTHESES, + CHAR_LEFT_SQUARE_BRACKET, + CHAR_PLUS, + CHAR_QUESTION_MARK, + CHAR_RIGHT_CURLY_BRACE, + CHAR_RIGHT_PARENTHESES, + CHAR_RIGHT_SQUARE_BRACKET + } = require_constants(), isPathSeparator = (code) => { + return code === CHAR_FORWARD_SLASH || code === CHAR_BACKWARD_SLASH; + }, depth = (token) => { + if (token.isPrefix !== !0) + token.depth = token.isGlobstar ? 1 / 0 : 1; + }, scan = (input, options) => { + let opts = options || {}, length = input.length - 1, scanToEnd = opts.parts === !0 || opts.scanToEnd === !0, slashes = [], tokens = [], parts = [], str = input, index = -1, start = 0, lastIndex = 0, isBrace = !1, isBracket = !1, isGlob = !1, isExtglob = !1, isGlobstar = !1, braceEscaped = !1, backslashes = !1, negated = !1, negatedExtglob = !1, finished = !1, braces = 0, prev, code, token = { value: "", depth: 0, isGlob: !1 }, eos = () => index >= length, peek = () => str.charCodeAt(index + 1), advance = () => { + return prev = code, str.charCodeAt(++index); + }; + while (index < length) { + code = advance(); + let next; + if (code === CHAR_BACKWARD_SLASH) { + if (backslashes = token.backslashes = !0, code = advance(), code === CHAR_LEFT_CURLY_BRACE) + braceEscaped = !0; + continue; + } + if (braceEscaped === !0 || code === CHAR_LEFT_CURLY_BRACE) { + braces++; + while (eos() !== !0 && (code = advance())) { + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = !0, advance(); + continue; + } + if (code === CHAR_LEFT_CURLY_BRACE) { + braces++; + continue; + } + if (braceEscaped !== !0 && code === CHAR_DOT && (code = advance()) === CHAR_DOT) { + if (isBrace = token.isBrace = !0, isGlob = token.isGlob = !0, finished = !0, scanToEnd === !0) + continue; + break; + } + if (braceEscaped !== !0 && code === CHAR_COMMA) { + if (isBrace = token.isBrace = !0, isGlob = token.isGlob = !0, finished = !0, scanToEnd === !0) + continue; + break; + } + if (code === CHAR_RIGHT_CURLY_BRACE) { + if (braces--, braces === 0) { + braceEscaped = !1, isBrace = token.isBrace = !0, finished = !0; + break; + } + } + } + if (scanToEnd === !0) + continue; + break; + } + if (code === CHAR_FORWARD_SLASH) { + if (slashes.push(index), tokens.push(token), token = { value: "", depth: 0, isGlob: !1 }, finished === !0) + continue; + if (prev === CHAR_DOT && index === start + 1) { + start += 2; + continue; + } + lastIndex = index + 1; + continue; + } + if (opts.noext !== !0) { + if ((code === CHAR_PLUS || code === CHAR_AT || code === CHAR_ASTERISK || code === CHAR_QUESTION_MARK || code === CHAR_EXCLAMATION_MARK) === !0 && peek() === CHAR_LEFT_PARENTHESES) { + if (isGlob = token.isGlob = !0, isExtglob = token.isExtglob = !0, finished = !0, code === CHAR_EXCLAMATION_MARK && index === start) + negatedExtglob = !0; + if (scanToEnd === !0) { + while (eos() !== !0 && (code = advance())) { + if (code === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = !0, code = advance(); + continue; + } + if (code === CHAR_RIGHT_PARENTHESES) { + isGlob = token.isGlob = !0, finished = !0; + break; + } + } + continue; + } + break; + } + } + if (code === CHAR_ASTERISK) { + if (prev === CHAR_ASTERISK) + isGlobstar = token.isGlobstar = !0; + if (isGlob = token.isGlob = !0, finished = !0, scanToEnd === !0) + continue; + break; + } + if (code === CHAR_QUESTION_MARK) { + if (isGlob = token.isGlob = !0, finished = !0, scanToEnd === !0) + continue; + break; + } + if (code === CHAR_LEFT_SQUARE_BRACKET) { + while (eos() !== !0 && (next = advance())) { + if (next === CHAR_BACKWARD_SLASH) { + backslashes = token.backslashes = !0, advance(); + continue; + } + if (next === CHAR_RIGHT_SQUARE_BRACKET) { + isBracket = token.isBracket = !0, isGlob = token.isGlob = !0, finished = !0; + break; + } + } + if (scanToEnd === !0) + continue; + break; + } + if (opts.nonegate !== !0 && code === CHAR_EXCLAMATION_MARK && index === start) { + negated = token.negated = !0, start++; + continue; + } + if (opts.noparen !== !0 && code === CHAR_LEFT_PARENTHESES) { + if (isGlob = token.isGlob = !0, scanToEnd === !0) { + while (eos() !== !0 && (code = advance())) { + if (code === CHAR_LEFT_PARENTHESES) { + backslashes = token.backslashes = !0, code = advance(); + continue; + } + if (code === CHAR_RIGHT_PARENTHESES) { + finished = !0; + break; + } + } + continue; + } + break; + } + if (isGlob === !0) { + if (finished = !0, scanToEnd === !0) + continue; + break; + } + } + if (opts.noext === !0) + isExtglob = !1, isGlob = !1; + let base = str, prefix = "", glob = ""; + if (start > 0) + prefix = str.slice(0, start), str = str.slice(start), lastIndex -= start; + if (base && isGlob === !0 && lastIndex > 0) + base = str.slice(0, lastIndex), glob = str.slice(lastIndex); + else if (isGlob === !0) + base = "", glob = str; + else + base = str; + if (base && base !== "" && base !== "/" && base !== str) { + if (isPathSeparator(base.charCodeAt(base.length - 1))) + base = base.slice(0, -1); + } + if (opts.unescape === !0) { + if (glob) + glob = utils.removeBackslashes(glob); + if (base && backslashes === !0) + base = utils.removeBackslashes(base); + } + let state = { + prefix, + input, + start, + base, + glob, + isBrace, + isBracket, + isGlob, + isExtglob, + isGlobstar, + negated, + negatedExtglob + }; + if (opts.tokens === !0) { + if (state.maxDepth = 0, !isPathSeparator(code)) + tokens.push(token); + state.tokens = tokens; + } + if (opts.parts === !0 || opts.tokens === !0) { + let prevIndex; + for (let idx = 0;idx < slashes.length; idx++) { + let n = prevIndex ? prevIndex + 1 : start, i = slashes[idx], value = input.slice(n, i); + if (opts.tokens) { + if (idx === 0 && start !== 0) + tokens[idx].isPrefix = !0, tokens[idx].value = prefix; + else + tokens[idx].value = value; + depth(tokens[idx]), state.maxDepth += tokens[idx].depth; + } + if (idx !== 0 || value !== "") + parts.push(value); + prevIndex = i; + } + if (prevIndex && prevIndex + 1 < input.length) { + let value = input.slice(prevIndex + 1); + if (parts.push(value), opts.tokens) + tokens[tokens.length - 1].value = value, depth(tokens[tokens.length - 1]), state.maxDepth += tokens[tokens.length - 1].depth; + } + state.slashes = slashes, state.parts = parts; + } + return state; + }; + module.exports = scan; +}); + +// node_modules/picomatch/lib/parse.js +var require_parse = __commonJS((exports, module) => { + var constants = require_constants(), utils = require_utils2(), { + MAX_LENGTH, + POSIX_REGEX_SOURCE, + REGEX_NON_SPECIAL_CHARS, + REGEX_SPECIAL_CHARS_BACKREF, + REPLACEMENTS + } = constants, expandRange = (args, options) => { + if (typeof options.expandRange === "function") + return options.expandRange(...args, options); + args.sort(); + let value = `[${args.join("-")}]`; + try { + new RegExp(value); + } catch (ex) { + return args.map((v) => utils.escapeRegex(v)).join(".."); + } + return value; + }, syntaxError = (type, char) => { + return `Missing ${type}: "${char}" - use "\\\\${char}" to match literal characters`; + }, parse5 = (input, options) => { + if (typeof input !== "string") + throw TypeError("Expected a string"); + input = REPLACEMENTS[input] || input; + let opts = { ...options }, max = typeof opts.maxLength === "number" ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH, len = input.length; + if (len > max) + throw SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); + let bos = { type: "bos", value: "", output: opts.prepend || "" }, tokens = [bos], capture = opts.capture ? "" : "?:", PLATFORM_CHARS = constants.globChars(opts.windows), EXTGLOB_CHARS = constants.extglobChars(PLATFORM_CHARS), { + DOT_LITERAL, + PLUS_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + DOTS_SLASH, + NO_DOT, + NO_DOT_SLASH, + NO_DOTS_SLASH, + QMARK, + QMARK_NO_DOT, + STAR, + START_ANCHOR + } = PLATFORM_CHARS, globstar = (opts2) => { + return `(${capture}(?:(?!${START_ANCHOR}${opts2.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; + }, nodot = opts.dot ? "" : NO_DOT, qmarkNoDot = opts.dot ? QMARK : QMARK_NO_DOT, star = opts.bash === !0 ? globstar(opts) : STAR; + if (opts.capture) + star = `(${star})`; + if (typeof opts.noext === "boolean") + opts.noextglob = opts.noext; + let state = { + input, + index: -1, + start: 0, + dot: opts.dot === !0, + consumed: "", + output: "", + prefix: "", + backtrack: !1, + negated: !1, + brackets: 0, + braces: 0, + parens: 0, + quotes: 0, + globstar: !1, + tokens + }; + input = utils.removePrefix(input, state), len = input.length; + let extglobs = [], braces = [], stack = [], prev = bos, value, eos = () => state.index === len - 1, peek = state.peek = (n = 1) => input[state.index + n], advance = state.advance = () => input[++state.index] || "", remaining = () => input.slice(state.index + 1), consume = (value2 = "", num = 0) => { + state.consumed += value2, state.index += num; + }, append = (token) => { + state.output += token.output != null ? token.output : token.value, consume(token.value); + }, negate = () => { + let count = 1; + while (peek() === "!" && (peek(2) !== "(" || peek(3) === "?")) + advance(), state.start++, count++; + if (count % 2 === 0) + return !1; + return state.negated = !0, state.start++, !0; + }, increment = (type) => { + state[type]++, stack.push(type); + }, decrement = (type) => { + state[type]--, stack.pop(); + }, push = (tok) => { + if (prev.type === "globstar") { + let isBrace = state.braces > 0 && (tok.type === "comma" || tok.type === "brace"), isExtglob = tok.extglob === !0 || extglobs.length && (tok.type === "pipe" || tok.type === "paren"); + if (tok.type !== "slash" && tok.type !== "paren" && !isBrace && !isExtglob) + state.output = state.output.slice(0, -prev.output.length), prev.type = "star", prev.value = "*", prev.output = star, state.output += prev.output; + } + if (extglobs.length && tok.type !== "paren") + extglobs[extglobs.length - 1].inner += tok.value; + if (tok.value || tok.output) + append(tok); + if (prev && prev.type === "text" && tok.type === "text") { + prev.output = (prev.output || prev.value) + tok.value, prev.value += tok.value; + return; + } + tok.prev = prev, tokens.push(tok), prev = tok; + }, extglobOpen = (type, value2) => { + let token = { ...EXTGLOB_CHARS[value2], conditions: 1, inner: "" }; + token.prev = prev, token.parens = state.parens, token.output = state.output; + let output = (opts.capture ? "(" : "") + token.open; + increment("parens"), push({ type, value: value2, output: state.output ? "" : ONE_CHAR }), push({ type: "paren", extglob: !0, value: advance(), output }), extglobs.push(token); + }, extglobClose = (token) => { + let output = token.close + (opts.capture ? ")" : ""), rest; + if (token.type === "negate") { + let extglobStar = star; + if (token.inner && token.inner.length > 1 && token.inner.includes("/")) + extglobStar = globstar(opts); + if (extglobStar !== star || eos() || /^\)+$/.test(remaining())) + output = token.close = `)$))${extglobStar}`; + if (token.inner.includes("*") && (rest = remaining()) && /^\.[^\\/.]+$/.test(rest)) { + let expression = parse5(rest, { ...options, fastpaths: !1 }).output; + output = token.close = `)${expression})${extglobStar})`; + } + if (token.prev.type === "bos") + state.negatedExtglob = !0; + } + push({ type: "paren", extglob: !0, value, output }), decrement("parens"); + }; + if (opts.fastpaths !== !1 && !/(^[*!]|[/()[\]{}"])/.test(input)) { + let backslashes = !1, output = input.replace(REGEX_SPECIAL_CHARS_BACKREF, (m, esc2, chars, first, rest, index) => { + if (first === "\\") + return backslashes = !0, m; + if (first === "?") { + if (esc2) + return esc2 + first + (rest ? QMARK.repeat(rest.length) : ""); + if (index === 0) + return qmarkNoDot + (rest ? QMARK.repeat(rest.length) : ""); + return QMARK.repeat(chars.length); + } + if (first === ".") + return DOT_LITERAL.repeat(chars.length); + if (first === "*") { + if (esc2) + return esc2 + first + (rest ? star : ""); + return star; + } + return esc2 ? m : `\\${m}`; + }); + if (backslashes === !0) + if (opts.unescape === !0) + output = output.replace(/\\/g, ""); + else + output = output.replace(/\\+/g, (m) => { + return m.length % 2 === 0 ? "\\\\" : m ? "\\" : ""; + }); + if (output === input && opts.contains === !0) + return state.output = input, state; + return state.output = utils.wrapOutput(output, state, options), state; + } + while (!eos()) { + if (value = advance(), value === "\x00") + continue; + if (value === "\\") { + let next = peek(); + if (next === "/" && opts.bash !== !0) + continue; + if (next === "." || next === ";") + continue; + if (!next) { + value += "\\", push({ type: "text", value }); + continue; + } + let match = /^\\+/.exec(remaining()), slashes = 0; + if (match && match[0].length > 2) { + if (slashes = match[0].length, state.index += slashes, slashes % 2 !== 0) + value += "\\"; + } + if (opts.unescape === !0) + value = advance(); + else + value += advance(); + if (state.brackets === 0) { + push({ type: "text", value }); + continue; + } + } + if (state.brackets > 0 && (value !== "]" || prev.value === "[" || prev.value === "[^")) { + if (opts.posix !== !1 && value === ":") { + let inner = prev.value.slice(1); + if (inner.includes("[")) { + if (prev.posix = !0, inner.includes(":")) { + let idx = prev.value.lastIndexOf("["), pre = prev.value.slice(0, idx), rest2 = prev.value.slice(idx + 2), posix = POSIX_REGEX_SOURCE[rest2]; + if (posix) { + if (prev.value = pre + posix, state.backtrack = !0, advance(), !bos.output && tokens.indexOf(prev) === 1) + bos.output = ONE_CHAR; + continue; + } + } + } + } + if (value === "[" && peek() !== ":" || value === "-" && peek() === "]") + value = `\\${value}`; + if (value === "]" && (prev.value === "[" || prev.value === "[^")) + value = `\\${value}`; + if (opts.posix === !0 && value === "!" && prev.value === "[") + value = "^"; + prev.value += value, append({ value }); + continue; + } + if (state.quotes === 1 && value !== '"') { + value = utils.escapeRegex(value), prev.value += value, append({ value }); + continue; + } + if (value === '"') { + if (state.quotes = state.quotes === 1 ? 0 : 1, opts.keepQuotes === !0) + push({ type: "text", value }); + continue; + } + if (value === "(") { + increment("parens"), push({ type: "paren", value }); + continue; + } + if (value === ")") { + if (state.parens === 0 && opts.strictBrackets === !0) + throw SyntaxError(syntaxError("opening", "(")); + let extglob = extglobs[extglobs.length - 1]; + if (extglob && state.parens === extglob.parens + 1) { + extglobClose(extglobs.pop()); + continue; + } + push({ type: "paren", value, output: state.parens ? ")" : "\\)" }), decrement("parens"); + continue; + } + if (value === "[") { + if (opts.nobracket === !0 || !remaining().includes("]")) { + if (opts.nobracket !== !0 && opts.strictBrackets === !0) + throw SyntaxError(syntaxError("closing", "]")); + value = `\\${value}`; + } else + increment("brackets"); + push({ type: "bracket", value }); + continue; + } + if (value === "]") { + if (opts.nobracket === !0 || prev && prev.type === "bracket" && prev.value.length === 1) { + push({ type: "text", value, output: `\\${value}` }); + continue; + } + if (state.brackets === 0) { + if (opts.strictBrackets === !0) + throw SyntaxError(syntaxError("opening", "[")); + push({ type: "text", value, output: `\\${value}` }); + continue; + } + decrement("brackets"); + let prevValue = prev.value.slice(1); + if (prev.posix !== !0 && prevValue[0] === "^" && !prevValue.includes("/")) + value = `/${value}`; + if (prev.value += value, append({ value }), opts.literalBrackets === !1 || utils.hasRegexChars(prevValue)) + continue; + let escaped = utils.escapeRegex(prev.value); + if (state.output = state.output.slice(0, -prev.value.length), opts.literalBrackets === !0) { + state.output += escaped, prev.value = escaped; + continue; + } + prev.value = `(${capture}${escaped}|${prev.value})`, state.output += prev.value; + continue; + } + if (value === "{" && opts.nobrace !== !0) { + increment("braces"); + let open = { + type: "brace", + value, + output: "(", + outputIndex: state.output.length, + tokensIndex: state.tokens.length + }; + braces.push(open), push(open); + continue; + } + if (value === "}") { + let brace = braces[braces.length - 1]; + if (opts.nobrace === !0 || !brace) { + push({ type: "text", value, output: value }); + continue; + } + let output = ")"; + if (brace.dots === !0) { + let arr = tokens.slice(), range = []; + for (let i = arr.length - 1;i >= 0; i--) { + if (tokens.pop(), arr[i].type === "brace") + break; + if (arr[i].type !== "dots") + range.unshift(arr[i].value); + } + output = expandRange(range, opts), state.backtrack = !0; + } + if (brace.comma !== !0 && brace.dots !== !0) { + let out = state.output.slice(0, brace.outputIndex), toks = state.tokens.slice(brace.tokensIndex); + brace.value = brace.output = "\\{", value = output = "\\}", state.output = out; + for (let t of toks) + state.output += t.output || t.value; + } + push({ type: "brace", value, output }), decrement("braces"), braces.pop(); + continue; + } + if (value === "|") { + if (extglobs.length > 0) + extglobs[extglobs.length - 1].conditions++; + push({ type: "text", value }); + continue; + } + if (value === ",") { + let output = value, brace = braces[braces.length - 1]; + if (brace && stack[stack.length - 1] === "braces") + brace.comma = !0, output = "|"; + push({ type: "comma", value, output }); + continue; + } + if (value === "/") { + if (prev.type === "dot" && state.index === state.start + 1) { + state.start = state.index + 1, state.consumed = "", state.output = "", tokens.pop(), prev = bos; + continue; + } + push({ type: "slash", value, output: SLASH_LITERAL }); + continue; + } + if (value === ".") { + if (state.braces > 0 && prev.type === "dot") { + if (prev.value === ".") + prev.output = DOT_LITERAL; + let brace = braces[braces.length - 1]; + prev.type = "dots", prev.output += value, prev.value += value, brace.dots = !0; + continue; + } + if (state.braces + state.parens === 0 && prev.type !== "bos" && prev.type !== "slash") { + push({ type: "text", value, output: DOT_LITERAL }); + continue; + } + push({ type: "dot", value, output: DOT_LITERAL }); + continue; + } + if (value === "?") { + if (!(prev && prev.value === "(") && opts.noextglob !== !0 && peek() === "(" && peek(2) !== "?") { + extglobOpen("qmark", value); + continue; + } + if (prev && prev.type === "paren") { + let next = peek(), output = value; + if (prev.value === "(" && !/[!=<:]/.test(next) || next === "<" && !/<([!=]|\w+>)/.test(remaining())) + output = `\\${value}`; + push({ type: "text", value, output }); + continue; + } + if (opts.dot !== !0 && (prev.type === "slash" || prev.type === "bos")) { + push({ type: "qmark", value, output: QMARK_NO_DOT }); + continue; + } + push({ type: "qmark", value, output: QMARK }); + continue; + } + if (value === "!") { + if (opts.noextglob !== !0 && peek() === "(") { + if (peek(2) !== "?" || !/[!=<:]/.test(peek(3))) { + extglobOpen("negate", value); + continue; + } + } + if (opts.nonegate !== !0 && state.index === 0) { + negate(); + continue; + } + } + if (value === "+") { + if (opts.noextglob !== !0 && peek() === "(" && peek(2) !== "?") { + extglobOpen("plus", value); + continue; + } + if (prev && prev.value === "(" || opts.regex === !1) { + push({ type: "plus", value, output: PLUS_LITERAL }); + continue; + } + if (prev && (prev.type === "bracket" || prev.type === "paren" || prev.type === "brace") || state.parens > 0) { + push({ type: "plus", value }); + continue; + } + push({ type: "plus", value: PLUS_LITERAL }); + continue; + } + if (value === "@") { + if (opts.noextglob !== !0 && peek() === "(" && peek(2) !== "?") { + push({ type: "at", extglob: !0, value, output: "" }); + continue; + } + push({ type: "text", value }); + continue; + } + if (value !== "*") { + if (value === "$" || value === "^") + value = `\\${value}`; + let match = REGEX_NON_SPECIAL_CHARS.exec(remaining()); + if (match) + value += match[0], state.index += match[0].length; + push({ type: "text", value }); + continue; + } + if (prev && (prev.type === "globstar" || prev.star === !0)) { + prev.type = "star", prev.star = !0, prev.value += value, prev.output = star, state.backtrack = !0, state.globstar = !0, consume(value); + continue; + } + let rest = remaining(); + if (opts.noextglob !== !0 && /^\([^?]/.test(rest)) { + extglobOpen("star", value); + continue; + } + if (prev.type === "star") { + if (opts.noglobstar === !0) { + consume(value); + continue; + } + let prior = prev.prev, before = prior.prev, isStart = prior.type === "slash" || prior.type === "bos", afterStar = before && (before.type === "star" || before.type === "globstar"); + if (opts.bash === !0 && (!isStart || rest[0] && rest[0] !== "/")) { + push({ type: "star", value, output: "" }); + continue; + } + let isBrace = state.braces > 0 && (prior.type === "comma" || prior.type === "brace"), isExtglob = extglobs.length && (prior.type === "pipe" || prior.type === "paren"); + if (!isStart && prior.type !== "paren" && !isBrace && !isExtglob) { + push({ type: "star", value, output: "" }); + continue; + } + while (rest.slice(0, 3) === "/**") { + let after = input[state.index + 4]; + if (after && after !== "/") + break; + rest = rest.slice(3), consume("/**", 3); + } + if (prior.type === "bos" && eos()) { + prev.type = "globstar", prev.value += value, prev.output = globstar(opts), state.output = prev.output, state.globstar = !0, consume(value); + continue; + } + if (prior.type === "slash" && prior.prev.type !== "bos" && !afterStar && eos()) { + state.output = state.output.slice(0, -(prior.output + prev.output).length), prior.output = `(?:${prior.output}`, prev.type = "globstar", prev.output = globstar(opts) + (opts.strictSlashes ? ")" : "|$)"), prev.value += value, state.globstar = !0, state.output += prior.output + prev.output, consume(value); + continue; + } + if (prior.type === "slash" && prior.prev.type !== "bos" && rest[0] === "/") { + let end = rest[1] !== void 0 ? "|$" : ""; + state.output = state.output.slice(0, -(prior.output + prev.output).length), prior.output = `(?:${prior.output}`, prev.type = "globstar", prev.output = `${globstar(opts)}${SLASH_LITERAL}|${SLASH_LITERAL}${end})`, prev.value += value, state.output += prior.output + prev.output, state.globstar = !0, consume(value + advance()), push({ type: "slash", value: "/", output: "" }); + continue; + } + if (prior.type === "bos" && rest[0] === "/") { + prev.type = "globstar", prev.value += value, prev.output = `(?:^|${SLASH_LITERAL}|${globstar(opts)}${SLASH_LITERAL})`, state.output = prev.output, state.globstar = !0, consume(value + advance()), push({ type: "slash", value: "/", output: "" }); + continue; + } + state.output = state.output.slice(0, -prev.output.length), prev.type = "globstar", prev.output = globstar(opts), prev.value += value, state.output += prev.output, state.globstar = !0, consume(value); + continue; + } + let token = { type: "star", value, output: star }; + if (opts.bash === !0) { + if (token.output = ".*?", prev.type === "bos" || prev.type === "slash") + token.output = nodot + token.output; + push(token); + continue; + } + if (prev && (prev.type === "bracket" || prev.type === "paren") && opts.regex === !0) { + token.output = value, push(token); + continue; + } + if (state.index === state.start || prev.type === "slash" || prev.type === "dot") { + if (prev.type === "dot") + state.output += NO_DOT_SLASH, prev.output += NO_DOT_SLASH; + else if (opts.dot === !0) + state.output += NO_DOTS_SLASH, prev.output += NO_DOTS_SLASH; + else + state.output += nodot, prev.output += nodot; + if (peek() !== "*") + state.output += ONE_CHAR, prev.output += ONE_CHAR; + } + push(token); + } + while (state.brackets > 0) { + if (opts.strictBrackets === !0) + throw SyntaxError(syntaxError("closing", "]")); + state.output = utils.escapeLast(state.output, "["), decrement("brackets"); + } + while (state.parens > 0) { + if (opts.strictBrackets === !0) + throw SyntaxError(syntaxError("closing", ")")); + state.output = utils.escapeLast(state.output, "("), decrement("parens"); + } + while (state.braces > 0) { + if (opts.strictBrackets === !0) + throw SyntaxError(syntaxError("closing", "}")); + state.output = utils.escapeLast(state.output, "{"), decrement("braces"); + } + if (opts.strictSlashes !== !0 && (prev.type === "star" || prev.type === "bracket")) + push({ type: "maybe_slash", value: "", output: `${SLASH_LITERAL}?` }); + if (state.backtrack === !0) { + state.output = ""; + for (let token of state.tokens) + if (state.output += token.output != null ? token.output : token.value, token.suffix) + state.output += token.suffix; + } + return state; + }; + parse5.fastpaths = (input, options) => { + let opts = { ...options }, max = typeof opts.maxLength === "number" ? Math.min(MAX_LENGTH, opts.maxLength) : MAX_LENGTH, len = input.length; + if (len > max) + throw SyntaxError(`Input length: ${len}, exceeds maximum allowed length: ${max}`); + input = REPLACEMENTS[input] || input; + let { + DOT_LITERAL, + SLASH_LITERAL, + ONE_CHAR, + DOTS_SLASH, + NO_DOT, + NO_DOTS, + NO_DOTS_SLASH, + STAR, + START_ANCHOR + } = constants.globChars(opts.windows), nodot = opts.dot ? NO_DOTS : NO_DOT, slashDot = opts.dot ? NO_DOTS_SLASH : NO_DOT, capture = opts.capture ? "" : "?:", state = { negated: !1, prefix: "" }, star = opts.bash === !0 ? ".*?" : STAR; + if (opts.capture) + star = `(${star})`; + let globstar = (opts2) => { + if (opts2.noglobstar === !0) + return star; + return `(${capture}(?:(?!${START_ANCHOR}${opts2.dot ? DOTS_SLASH : DOT_LITERAL}).)*?)`; + }, create = (str) => { + switch (str) { + case "*": + return `${nodot}${ONE_CHAR}${star}`; + case ".*": + return `${DOT_LITERAL}${ONE_CHAR}${star}`; + case "*.*": + return `${nodot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; + case "*/*": + return `${nodot}${star}${SLASH_LITERAL}${ONE_CHAR}${slashDot}${star}`; + case "**": + return nodot + globstar(opts); + case "**/*": + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${ONE_CHAR}${star}`; + case "**/*.*": + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${slashDot}${star}${DOT_LITERAL}${ONE_CHAR}${star}`; + case "**/.*": + return `(?:${nodot}${globstar(opts)}${SLASH_LITERAL})?${DOT_LITERAL}${ONE_CHAR}${star}`; + default: { + let match = /^(.*?)\.(\w+)$/.exec(str); + if (!match) + return; + let source2 = create(match[1]); + if (!source2) + return; + return source2 + DOT_LITERAL + match[2]; + } + } + }, output = utils.removePrefix(input, state), source = create(output); + if (source && opts.strictSlashes !== !0) + source += `${SLASH_LITERAL}?`; + return source; + }; + module.exports = parse5; +}); + +// node_modules/picomatch/lib/picomatch.js +var require_picomatch = __commonJS((exports, module) => { + var scan = require_scan(), parse5 = require_parse(), utils = require_utils2(), constants = require_constants(), isObject2 = (val) => val && typeof val === "object" && !Array.isArray(val), picomatch = (glob, options, returnState = !1) => { + if (Array.isArray(glob)) { + let fns = glob.map((input) => picomatch(input, options, returnState)); + return (str) => { + for (let isMatch of fns) { + let state2 = isMatch(str); + if (state2) + return state2; + } + return !1; + }; + } + let isState = isObject2(glob) && glob.tokens && glob.input; + if (glob === "" || typeof glob !== "string" && !isState) + throw TypeError("Expected pattern to be a non-empty string"); + let opts = options || {}, posix = opts.windows, regex = isState ? picomatch.compileRe(glob, options) : picomatch.makeRe(glob, options, !1, !0), state = regex.state; + delete regex.state; + let isIgnored = () => !1; + if (opts.ignore) { + let ignoreOpts = { ...options, ignore: null, onMatch: null, onResult: null }; + isIgnored = picomatch(opts.ignore, ignoreOpts, returnState); + } + let matcher = (input, returnObject = !1) => { + let { isMatch, match, output } = picomatch.test(input, regex, options, { glob, posix }), result = { glob, state, regex, posix, input, output, match, isMatch }; + if (typeof opts.onResult === "function") + opts.onResult(result); + if (isMatch === !1) + return result.isMatch = !1, returnObject ? result : !1; + if (isIgnored(input)) { + if (typeof opts.onIgnore === "function") + opts.onIgnore(result); + return result.isMatch = !1, returnObject ? result : !1; + } + if (typeof opts.onMatch === "function") + opts.onMatch(result); + return returnObject ? result : !0; + }; + if (returnState) + matcher.state = state; + return matcher; + }; + picomatch.test = (input, regex, options, { glob, posix } = {}) => { + if (typeof input !== "string") + throw TypeError("Expected input to be a string"); + if (input === "") + return { isMatch: !1, output: "" }; + let opts = options || {}, format = opts.format || (posix ? utils.toPosixSlashes : null), match = input === glob, output = match && format ? format(input) : input; + if (match === !1) + output = format ? format(input) : input, match = output === glob; + if (match === !1 || opts.capture === !0) + if (opts.matchBase === !0 || opts.basename === !0) + match = picomatch.matchBase(input, regex, options, posix); + else + match = regex.exec(output); + return { isMatch: Boolean(match), match, output }; + }; + picomatch.matchBase = (input, glob, options) => { + return (glob instanceof RegExp ? glob : picomatch.makeRe(glob, options)).test(utils.basename(input)); + }; + picomatch.isMatch = (str, patterns, options) => picomatch(patterns, options)(str); + picomatch.parse = (pattern, options) => { + if (Array.isArray(pattern)) + return pattern.map((p) => picomatch.parse(p, options)); + return parse5(pattern, { ...options, fastpaths: !1 }); + }; + picomatch.scan = (input, options) => scan(input, options); + picomatch.compileRe = (state, options, returnOutput = !1, returnState = !1) => { + if (returnOutput === !0) + return state.output; + let opts = options || {}, prepend = opts.contains ? "" : "^", append = opts.contains ? "" : "$", source = `${prepend}(?:${state.output})${append}`; + if (state && state.negated === !0) + source = `^(?!${source}).*$`; + let regex = picomatch.toRegex(source, options); + if (returnState === !0) + regex.state = state; + return regex; + }; + picomatch.makeRe = (input, options = {}, returnOutput = !1, returnState = !1) => { + if (!input || typeof input !== "string") + throw TypeError("Expected a non-empty string"); + let parsed = { negated: !1, fastpaths: !0 }; + if (options.fastpaths !== !1 && (input[0] === "." || input[0] === "*")) + parsed.output = parse5.fastpaths(input, options); + if (!parsed.output) + parsed = parse5(input, options); + return picomatch.compileRe(parsed, options, returnOutput, returnState); + }; + picomatch.toRegex = (source, options) => { + try { + let opts = options || {}; + return new RegExp(source, opts.flags || (opts.nocase ? "i" : "")); + } catch (err) { + if (options && options.debug === !0) + throw err; + return /$^/; + } + }; + picomatch.constants = constants; + module.exports = picomatch; +}); + +// node_modules/picomatch/index.js +var require_picomatch2 = __commonJS((exports, module) => { + var pico = require_picomatch(), utils = require_utils2(); + function picomatch(glob, options, returnState = !1) { + if (options && (options.windows === null || options.windows === void 0)) + options = { ...options, windows: utils.isWindows() }; + return pico(glob, options, returnState); + } + Object.assign(picomatch, pico); + module.exports = picomatch; +}); + // ij-mcp-proxy.ts import path9 from "path"; import { cwd, env } from "process"; @@ -20900,7 +21961,7 @@ function normalizePortList(preferredPorts, portScanStart, portScanLimit) { return candidates; } -class StreamTransport { +class StreamTransportImpl { _options; _queue; _connectPromise; @@ -20938,7 +21999,7 @@ class StreamTransport { if (this._protocolVersion = version2, this._transport?.setProtocolVersion) this._transport.setProtocolVersion(version2); } - async _resetTransport(reason) { + async resetTransport(reason) { let warn = this._options.warn, message = reason instanceof Error ? reason.message : String(reason); if (warn) warn(`MCP stream session invalid; reconnecting. ${message}`); @@ -20963,7 +22024,7 @@ class StreamTransport { } catch (error48) { let err = error48 instanceof Error ? error48 : Error(String(error48)); if (!retried && isSessionNotFoundError(err)) { - retried = !0, await this._resetTransport(err); + retried = !0, await this.resetTransport(err); continue; } if (this.onerror) @@ -21100,7 +22161,7 @@ function createStreamTransport({ warn, probeHost = "127.0.0.1" }) { - return new StreamTransport({ + return new StreamTransportImpl({ explicitUrl, preferredPorts, portScanStart, @@ -21119,9 +22180,9 @@ function createStreamTransport({ } // workarounds.ts -var FULL_VERSION_RE = /\b\d{4}\.\d+(?:\.\d+){0,2}\b/, BUILD_VERSION_RE = /\b\d{3}\.\d+(?:\.\d+)?\b/; +var FULL_VERSION_RE = /\b\d{4}\.\d+(?:\.\d+){0,2}\b/, BUILD_VERSION_RE = /\b\d{3}\.\d+(?:\.\d+)?\b/, SNAPSHOT_BUILD_RE = /\b(\d{3})\.SNAPSHOT\b/i, SNAPSHOT_BUILD_PART = Number.MAX_SAFE_INTEGER, ANY_VERSION_RE = /\d+(?:\.\d+)+/; var WORKAROUND_FIXED_IN = { - ["search_in_files_by_regex_directory_scope_ignored" /* SearchInFilesByRegexDirectoryScopeIgnored */]: "261.SNAPSHOT" + ["search_in_files_by_regex_directory_scope_ignored" /* SearchInFilesByRegexDirectoryScopeIgnored */]: "261.20247" }, currentIdeVersion = null; function setIdeVersion(rawVersion) { if (!rawVersion) { @@ -21130,14 +22191,73 @@ function setIdeVersion(rawVersion) { } currentIdeVersion = parseIdeVersion(rawVersion); } +function shouldApplyWorkaround(key) { + if (isWorkaroundDisabled(key)) + return logDebug(`Workaround ${key} not used (disabled by env)`), !1; + let fixedInRaw = (WORKAROUND_FIXED_IN[key] ?? "").trim(); + if (!fixedInRaw) + return !0; + let ideVersion = currentIdeVersion; + if (!ideVersion) + return !0; + let fixedSpec = parseVersionSpec(fixedInRaw); + if (!fixedSpec) + return !0; + let currentParts = fixedSpec.kind === "build" ? ideVersion.build ?? deriveBuildFromFull(ideVersion.full) : ideVersion.full; + if (!currentParts) + return !0; + if (compareVersionParts(currentParts, fixedSpec.parts) >= 0) + return logDebug(`Workaround ${key} not used; fixed in ${fixedInRaw}, ide ${ideVersion.raw}`), !1; + return !0; +} +function isWorkaroundDisabled(key) { + let disabledAll = process.env.JETBRAINS_MCP_PROXY_DISABLE_WORKAROUNDS; + if (disabledAll && disabledAll !== "false" && disabledAll !== "0") + return !0; + let disabledKeys = process.env.JETBRAINS_MCP_PROXY_DISABLE_WORKAROUND_KEYS; + if (!disabledKeys) + return !1; + return disabledKeys.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0).includes(key); +} +function logDebug(message) { + let enabled = process.env.JETBRAINS_MCP_PROXY_WORKAROUND_DEBUG; + if (!enabled || enabled === "0" || enabled === "false") + return; + process.stderr.write(`[ij-mcp-proxy] ${message} +`); +} function parseIdeVersion(raw) { let full = extractVersionParts(raw, FULL_VERSION_RE), build = extractVersionParts(raw, BUILD_VERSION_RE); + if (!build) { + let snapshotMatch = raw.match(SNAPSHOT_BUILD_RE); + if (snapshotMatch) { + let train = Number.parseInt(snapshotMatch[1], 10); + if (!Number.isNaN(train)) + build = [train, SNAPSHOT_BUILD_PART]; + } + } return { raw, full: full ?? void 0, build: build ?? void 0 }; } +function parseVersionSpec(version2) { + let snapshotMatch = version2.match(SNAPSHOT_BUILD_RE); + if (snapshotMatch) { + let train = Number.parseInt(snapshotMatch[1], 10); + if (!Number.isNaN(train)) + return { parts: [train], kind: "build" }; + } + let match = version2.match(ANY_VERSION_RE); + if (!match) + return null; + let parts = parseVersionParts(match[0]); + if (!parts) + return null; + let kind = parts[0] >= 1000 ? "full" : "build"; + return { parts, kind }; +} function extractVersionParts(raw, regex) { let match = raw.match(regex); if (!match) @@ -21150,6 +22270,25 @@ function parseVersionParts(value) { return null; return parts; } +function deriveBuildFromFull(full) { + if (!full || full.length < 2) + return null; + let year = full[0], minor = full[1]; + if (!Number.isFinite(year) || !Number.isFinite(minor)) + return null; + if (year < 2000 || year > 2100) + return null; + return [(year - 2000) * 10 + minor]; +} +function compareVersionParts(left, right) { + let maxLength = Math.max(left.length, right.length); + for (let i = 0;i < maxLength; i += 1) { + let leftValue = left[i] ?? 0, rightValue = right[i] ?? 0; + if (leftValue !== rightValue) + return leftValue - rightValue; + } + return 0; +} // proxy-tools/handlers/apply-patch.ts import { copyFile, mkdir, rename, rm } from "fs/promises"; @@ -21234,16 +22373,6 @@ function resolvePathInProject(projectPath, inputPath, label) { throw Error(`${label} must be within the project root`); return { absolute, relative }; } -function resolveSearchPath(projectPath, inputPath) { - if (inputPath === void 0 || inputPath === null) - return { absolute: projectPath, relative: "" }; - return resolvePathInProject(projectPath, inputPath, "path"); -} -function looksLikeFilePath(rawPath, relativePath) { - if (rawPath.endsWith(path.sep) || rawPath.endsWith("/") || rawPath.endsWith("\\")) - return !1; - return path.extname(relativePath) !== ""; -} function normalizeEntryPath(projectPath, filePath) { if (typeof filePath !== "string" || filePath === "") return filePath; @@ -21292,29 +22421,29 @@ function isRecord(value) { } function coerceSearchItem(value) { if (typeof value === "string") - return [value]; + return { filePath: value }; if (Array.isArray(value)) { if (value.length === 0 || value.length > 3) return null; if (typeof value[0] !== "string") return null; - let line = typeof value[1] === "number" ? value[1] : void 0, text = typeof value[2] === "string" ? value[2] : void 0; - if (line === void 0) - return [value[0]]; - if (text === void 0) - return [value[0], line]; - return [value[0], line, text]; + let item = { filePath: value[0] }; + if (typeof value[1] === "number") { + if (item.lineNumber = value[1], typeof value[2] === "string") + item.lineText = value[2]; + } + return item; } if (isRecord(value)) { let filePath = typeof value.filePath === "string" ? value.filePath : null; if (!filePath) return null; - let lineNumber = typeof value.lineNumber === "number" ? value.lineNumber : void 0, lineText = typeof value.lineText === "string" ? value.lineText : void 0; - if (lineNumber === void 0) - return [filePath]; - if (lineText === void 0) - return [filePath, lineNumber]; - return [filePath, lineNumber, lineText]; + let item = { filePath }; + if (typeof value.lineNumber === "number") + item.lineNumber = value.lineNumber; + if (typeof value.lineText === "string") + item.lineText = value.lineText; + return item; } return null; } @@ -21351,27 +22480,14 @@ function extractItemsFromValue(value) { } function itemsToEntries(items) { return items.map((item) => ({ - filePath: item[0], - lineNumber: item.length > 1 ? item[1] : void 0, - lineText: item.length > 2 ? item[2] : void 0 + filePath: item.filePath, + lineNumber: item.lineNumber, + lineText: item.lineText })); } function extractItems(result) { - let structured = extractStructuredContent(result), fromStructured = extractItemsFromValue(structured); - if (fromStructured) - return fromStructured; - let text = extractTextFromResult(result); - if (!text) - return []; - let trimmed = text.trim(); - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) - return []; - try { - let parsed = JSON.parse(trimmed); - return extractItemsFromValue(parsed) ?? []; - } catch { - return []; - } + let structured = extractStructuredContent(result); + return extractItemsFromValue(structured) ?? []; } function coerceEntries(value) { if (!Array.isArray(value)) @@ -21404,47 +22520,27 @@ function extractResultsMapFromValue(value) { return results; } function extractResultsMap(result) { - let structured = extractStructuredContent(result), fromStructured = extractResultsMapFromValue(structured); - if (fromStructured) - return fromStructured; - let text = extractTextFromResult(result); - if (!text) - return null; - let trimmed = text.trim(); - if (!trimmed.startsWith("{")) - return null; - try { - return extractResultsMapFromValue(JSON.parse(trimmed)); - } catch { - return null; - } + let structured = extractStructuredContent(result); + return extractResultsMapFromValue(structured); } function extractFileList(result) { let resultsMap = extractResultsMap(result); if (resultsMap) return extractFileListFromResults(resultsMap); let structured = extractStructuredContent(result); - if (structured) { - let structuredRecord = structured; - if (Array.isArray(structuredRecord.items)) - return extractItems(result).map((item) => item[0]); - if (Array.isArray(structuredRecord.files)) - return structuredRecord.files; - if (Array.isArray(structured)) - return structured; - } - let text = extractTextFromResult(result); - if (!text) - return []; - try { - let parsed = JSON.parse(text); - if (Array.isArray(parsed.files)) - return parsed.files; - if (Array.isArray(parsed)) - return parsed; - } catch { + if (!structured) return []; + if (Array.isArray(structured)) { + let items2 = extractItemsFromValue(structured); + if (items2) + return items2.map((item) => item.filePath); + return structured; } + let structuredRecord = structured, items = extractItemsFromValue(structuredRecord); + if (items) + return items.map((item) => item.filePath); + if (Array.isArray(structuredRecord.files)) + return structuredRecord.files; return []; } function extractEntries(result) { @@ -21468,23 +22564,6 @@ function extractEntries(result) { return structured; } } - let text = extractTextFromResult(result); - if (!text) - return []; - try { - let parsed = JSON.parse(text); - if (Array.isArray(parsed.entries)) - return parsed.entries; - if (Array.isArray(parsed.results)) - return parsed.results; - let fromItems = extractItemsFromValue(parsed); - if (fromItems) - return itemsToEntries(fromItems); - if (Array.isArray(parsed)) - return parsed; - } catch { - return []; - } return []; } function flattenResultsMap(results) { @@ -22059,8 +23138,36 @@ function formatEntry(entry) { // proxy-tools/handlers/read.ts var DEFAULT_READ_LIMIT = 2000, MAX_LINE_LENGTH = 500, TAB_WIDTH = 4, COMMENT_PREFIXES = ["#", "//", "--"], BLOCK_COMMENT_START = "/*", BLOCK_COMMENT_END = "*/", ANNOTATION_PREFIX = "@", TRUNCATION_ERROR2 = "file content truncated while reading"; -async function handleReadTool(args, projectPath, callUpstreamTool, { format = "numbered" } = {}) { +async function handleReadTool(args, projectPath, callUpstreamTool, readCapabilities, { format = "numbered" } = {}) { let filePath = requireString(args.file_path, "file_path"), offset = toPositiveInt(args.offset, 1, "offset"), limit = toPositiveInt(args.limit, DEFAULT_READ_LIMIT, "limit"), mode = (args.mode ? String(args.mode).toLowerCase() : "slice") === "indentation" ? "indentation" : "slice", includeLineNumbers = format !== "raw", indentation = args.indentation ?? {}, anchorLine = indentation.anchor_line === void 0 || indentation.anchor_line === null ? null : toPositiveInt(indentation.anchor_line, void 0, "anchor_line"), maxLevels = toNonNegativeInt(indentation.max_levels, 0, "max_levels"), includeSiblings = Boolean(indentation.include_siblings ?? !1), includeHeader = indentation.include_header === void 0 ? !0 : Boolean(indentation.include_header), maxLines = indentation.max_lines === void 0 || indentation.max_lines === null ? null : toPositiveInt(indentation.max_lines, void 0, "max_lines"), { relative, absolute } = resolvePathInProject(projectPath, filePath, "file_path"); + if (format !== "raw" && readCapabilities.hasReadFile) { + let upstreamArgs = { + file_path: relative, + offset, + limit + }; + if (mode === "indentation") { + upstreamArgs.mode = "indentation"; + let indentationPayload = { + include_siblings: includeSiblings, + include_header: includeHeader, + max_levels: maxLevels + }; + if (anchorLine != null) + indentationPayload.anchor_line = anchorLine; + if (maxLines != null) + indentationPayload.max_lines = maxLines; + upstreamArgs.indentation = indentationPayload; + } else if (args.mode) + upstreamArgs.mode = "slice"; + try { + let result = await callUpstreamTool("read_file", upstreamArgs), text = extractTextFromResult(result); + if (typeof text === "string") + return text; + if (typeof result === "string") + return result; + } catch {} + } if (mode === "indentation") try { return await readIndentationMode(relative, offset, limit, { @@ -22394,273 +23501,16 @@ async function handleRenameTool(args, projectPath, callUpstreamTool) { return `Renamed ${symbolName} to ${newName} in ${path5.resolve(projectPath, relative)}`; } -// proxy-tools/handlers/search.ts -import path7 from "path"; - -// proxy-tools/handlers/find.ts +// proxy-tools/handlers/search-shared.ts import path6 from "path"; -var DEFAULT_LIMIT2 = 1000, NAME_SEARCH_MAX_LIMIT = 1e4, GLOB_CHARS_RE = /[*?\[\]{}]/; -function resolvePattern(args) { - if (args && typeof args.pattern === "string") - return args.pattern; - if (args && typeof args.query === "string") - return args.query; - if (args && typeof args.name === "string") - return args.name; - return null; -} -function normalizeMode(value) { - if (typeof value !== "string") - return "auto"; - let mode = value.trim().toLowerCase(); - if (mode === "") - return "auto"; - if (mode === "auto" || mode === "glob" || mode === "name") - return mode; - throw Error("mode must be one of: auto, glob, name"); -} -function shouldUseGlob(pattern, mode) { - if (mode === "glob") - return !0; - if (mode === "name") - return !1; - return GLOB_CHARS_RE.test(pattern) || pattern.includes("/") || pattern.includes("\\"); -} -function filterByBasePath(files, projectPath, baseRelative) { - if (!baseRelative) - return files; - let normalizedBase = path6.normalize(baseRelative), prefix = normalizedBase.endsWith(path6.sep) ? normalizedBase : `${normalizedBase}${path6.sep}`; - return files.filter((file2) => { - let relative = path6.isAbsolute(file2) ? path6.relative(projectPath, file2) : file2; - return relative === normalizedBase || relative.startsWith(prefix); - }); -} -function extractFilesResult(result) { - let files = extractFileList(result), structured = extractStructuredContent(result), structuredRecord = structured && typeof structured === "object" ? structured : null; - return { - files, - probablyHasMoreMatchingFiles: structuredRecord?.probablyHasMoreMatchingFiles === !0, - timedOut: structuredRecord?.timedOut === !0 - }; -} -async function findByNameKeyword(pattern, projectPath, baseRelative, limit, callUpstreamTool) { - let shouldFilter = Boolean(baseRelative), requestLimit = shouldFilter ? Math.max(limit, DEFAULT_LIMIT2) : limit, maxLimit = shouldFilter ? Math.max(limit, NAME_SEARCH_MAX_LIMIT) : limit, timedOut = !1, probablyHasMoreMatchingFiles = !1; - while (!0) { - let result = await callUpstreamTool("find_files_by_name_keyword", { - nameKeyword: pattern, - fileCountLimit: requestLimit - }), extracted = extractFilesResult(result), files = extracted.files; - timedOut = timedOut || extracted.timedOut; - let hasMoreHint = extracted.probablyHasMoreMatchingFiles || files.length >= requestLimit; - probablyHasMoreMatchingFiles = probablyHasMoreMatchingFiles || hasMoreHint; - let filtered = shouldFilter ? filterByBasePath(files, projectPath, baseRelative) : files, reachedLimit = filtered.length >= limit; - if (!shouldFilter || reachedLimit || files.length < requestLimit || requestLimit >= maxLimit) - return { - files: filtered.slice(0, limit), - timedOut, - probablyHasMoreMatchingFiles: timedOut || probablyHasMoreMatchingFiles || reachedLimit - }; - requestLimit = Math.min(requestLimit * 2, maxLimit); - } -} -async function findFiles(args, projectPath, callUpstreamTool) { - let rawPattern = resolvePattern(args), pattern = requireString(rawPattern, "pattern").trim(), mode = normalizeMode(args?.mode), limit = toPositiveInt(args?.limit, DEFAULT_LIMIT2, "limit"), basePath = args?.path, { relative } = resolveSearchPath(projectPath, basePath); - if (shouldUseGlob(pattern, mode)) { - let toolArgs = { globPattern: pattern, fileCountLimit: limit }; - if (relative) - toolArgs.subDirectoryRelativePath = relative; - if (args?.add_excluded !== void 0) - toolArgs.addExcluded = Boolean(args.add_excluded); - let result = await callUpstreamTool("find_files_by_glob", toolArgs), extracted = extractFilesResult(result), limited = extracted.files.slice(0, limit), reachedLimit = limited.length >= limit; - return { - files: limited, - timedOut: extracted.timedOut, - probablyHasMoreMatchingFiles: extracted.timedOut || extracted.probablyHasMoreMatchingFiles || reachedLimit - }; - } - return await findByNameKeyword(pattern, projectPath, relative, limit, callUpstreamTool); -} -// proxy-tools/handlers/search.ts -var DEFAULT_MAX_RESULTS = 100, GLOB_CHARS_RE2 = /[*?\[\]{}]/; -async function handleSearchTool(args, projectPath, callUpstreamTool, capabilities) { - let query = requireString(resolveQuery(args), "query").trim(), rawTarget = normalizeString(args.target ?? args.kind), rawQueryType = normalizeString(args.query_type ?? args.queryType ?? args.type), rawOutput = normalizeString(args.output ?? args.output_mode), pathArg = resolvePathArg(args), fileMask = resolveFileMask(args), caseSensitive = resolveBoolean(args.case_sensitive ?? args.caseSensitive, !0), maxResults = toPositiveInt(args.max_results ?? args.maxResults ?? args.limit, DEFAULT_MAX_RESULTS, "max_results"), target = resolveTarget(query, rawTarget, rawQueryType, capabilities), queryType = resolveQueryType(query, target, rawQueryType, capabilities), output = resolveOutput(rawOutput, target); - if (target === "symbol") - return await searchSymbols(query, { - pathArg, - fileMask, - maxResults, - output - }, projectPath, callUpstreamTool, capabilities); - if (target === "file") - return await searchFiles(query, { - pathArg, - maxResults, - queryType - }, projectPath, callUpstreamTool, capabilities); - return await searchText(query, { - pathArg, - fileMask, - caseSensitive, - maxResults, - output, - queryType - }, projectPath, callUpstreamTool, capabilities); -} -function resolveQuery(args) { - if (args.query !== void 0) - return args.query; - if (args.pattern !== void 0) - return args.pattern; - if (args.text !== void 0) - return args.text; - if (args.name !== void 0) - return args.name; - return args.query; -} -function normalizeString(value) { - if (typeof value !== "string") - return null; - let trimmed = value.trim().toLowerCase(); - return trimmed === "" ? null : trimmed; -} -function resolveBoolean(value, fallback) { - if (value === void 0 || value === null) - return fallback; - if (typeof value === "boolean") - return value; - if (typeof value === "number") - return value !== 0; - if (typeof value === "string") { - let normalized = value.trim().toLowerCase(); - if (normalized === "false" || normalized === "0" || normalized === "no") - return !1; - if (normalized === "true" || normalized === "1" || normalized === "yes") - return !0; - } - return Boolean(value); -} -function resolvePathArg(args) { - if (args.path !== void 0) - return args.path; - if (args.directory !== void 0) - return args.directory; - if (args.directory_to_search !== void 0) - return args.directory_to_search; - if (args.directoryToSearch !== void 0) - return args.directoryToSearch; - return; -} -function resolveFileMask(args) { - if (typeof args.file_mask === "string") - return args.file_mask; - if (typeof args.fileMask === "string") - return args.fileMask; - if (typeof args.mask === "string") - return args.mask; - return; -} -function resolveTarget(query, rawTarget, rawQueryType, capabilities) { - let allowedTargets = /* @__PURE__ */ new Set; - if (capabilities.supportsSymbol) - allowedTargets.add("symbol"); - if (capabilities.supportsFile) - allowedTargets.add("file"); - if (capabilities.supportsText) - allowedTargets.add("text"); - if (rawTarget && rawTarget !== "auto") { - if (!allowedTargets.has(rawTarget)) - throw Error(`target must be one of: auto, ${[...allowedTargets].join(", ")}`); - return rawTarget; - } - if (rawQueryType === "glob") - return "file"; - if (rawQueryType === "regex") - return "text"; - if (looksLikeGlob(query) || looksLikePath(query)) - return "file"; - if (containsWhitespace(query)) - return "text"; - if (capabilities.supportsSymbol) - return "symbol"; - if (capabilities.supportsFile) - return "file"; - return "text"; -} -function resolveQueryType(query, target, rawQueryType, capabilities) { - let queryType; - if (rawQueryType === "regex" || rawQueryType === "glob" || rawQueryType === "text") - queryType = rawQueryType; - else - queryType = target === "file" && looksLikeGlob(query) ? "glob" : "text"; - if (queryType === "glob" && target !== "file") - throw Error("query_type=glob requires target=file"); - if (queryType === "regex" && target === "file") - throw Error("query_type=regex requires target=text"); - if (queryType === "regex" && !capabilities.supportsRegex) - throw Error("query_type=regex is not supported by this IDE version"); - if (queryType === "glob" && !capabilities.supportsFileGlob) - throw Error("query_type=glob is not supported by this IDE version"); - if (target === "file" && queryType === "text" && !capabilities.supportsFileName) { - if (capabilities.supportsFileGlob) - return "glob"; - throw Error("file name search is not supported by this IDE version"); - } - return queryType; -} -function resolveOutput(rawOutput, target) { - if (!rawOutput) - return target === "file" ? "files" : "entries"; - if (rawOutput === "files" || rawOutput === "entries") - return rawOutput; - throw Error("output must be one of: entries, files"); -} -async function searchSymbols(query, options, projectPath, callUpstreamTool, capabilities) { - if (!capabilities.supportsSymbol) - throw Error("symbol search is not supported by this IDE version"); - let { relative } = resolveSearchPath(projectPath, options.pathArg), toolArgs = { - query, - maxResults: options.maxResults, - output: options.output === "files" ? "files" : "entries", - providers: ["classes", "symbols"] - }; - if (relative) - toolArgs.directoryToSearch = relative; - if (options.fileMask) - toolArgs.fileMask = options.fileMask; - let result = await callUpstreamTool("search", toolArgs), items = normalizeItems(extractItems(result), projectPath, options.maxResults, options.output === "entries"), more = resolveMoreFlag(result, items.length, options.maxResults); - return serializeSearchResult({ items, more }); -} -async function searchFiles(query, options, projectPath, callUpstreamTool, capabilities) { - if (!capabilities.supportsFile) - throw Error("file search is not supported by this IDE version"); - let mode = options.queryType === "glob" ? "glob" : "name", result = await findFiles({ - pattern: query, - mode, - limit: options.maxResults, - path: options.pathArg - }, projectPath, callUpstreamTool), items = normalizeItemsFromFiles(result.files, projectPath, options.maxResults), more = result.timedOut || result.probablyHasMoreMatchingFiles || result.files.length >= options.maxResults; - return serializeSearchResult({ items, more }); -} -async function searchText(query, options, projectPath, callUpstreamTool, capabilities) { - if (!capabilities.supportsText) - throw Error("text search is not supported by this IDE version"); - let { relative } = resolveSearchPath(projectPath, options.pathArg), directoryToSearch = relative || void 0, fileMask = options.fileMask, treatAsFile = !1; - if (relative && typeof options.pathArg === "string" && looksLikeFilePath(options.pathArg, relative)) - treatAsFile = !0, directoryToSearch = path7.dirname(relative), fileMask = fileMask ?? path7.basename(relative); - let maxUsageCount = options.output === "files" ? Math.min(options.maxResults * 5, 1000) : options.maxResults, { entries, probablyHasMoreMatchingEntries, timedOut } = await searchInFiles({ - directoryToSearch, - fileMask, - caseSensitive: options.caseSensitive, - maxUsageCount, - ...options.queryType === "regex" ? { regexPattern: query } : { searchText: query } - }, callUpstreamTool), filtered = normalizeEntries(filterEntriesByPath(entries, projectPath, relative, treatAsFile), projectPath), entryCount = filtered.length, more = timedOut || probablyHasMoreMatchingEntries || entryCount >= options.maxResults; - if (options.output === "files") { - let items2 = normalizeItemsFromEntries(filtered, projectPath, options.maxResults, !1); - return serializeSearchResult({ items: items2, more }); - } - let items = normalizeItemsFromEntries(filtered, projectPath, options.maxResults, !0); - return serializeSearchResult({ items, more }); +// proxy-tools/handlers/search-constants.ts +var DEFAULT_MAX_RESULTS = 1000, MAX_RESULTS_UPPER_BOUND = 5000, SEARCH_SCOPE_MULTIPLIER = 5; + +// proxy-tools/handlers/search-shared.ts +function normalizeLimit(value, fallback = DEFAULT_MAX_RESULTS) { + let parsed = toPositiveInt(value, fallback, "limit") ?? fallback; + return Math.min(parsed, MAX_RESULTS_UPPER_BOUND); } function serializeSearchResult(payload) { let result = { items: payload.items }; @@ -22668,35 +23518,21 @@ function serializeSearchResult(payload) { result.more = !0; return JSON.stringify(result); } -function normalizeProjectRelativePath(projectPath, filePath) { - if (!filePath) - return ""; - if (path7.isAbsolute(filePath)) { - let relative = path7.relative(projectPath, filePath); - if (!relative.startsWith("..") && !path7.isAbsolute(relative)) - return relative; - return path7.normalize(filePath); - } - return path7.normalize(filePath); -} -function normalizeEntries(entries, projectPath) { - return entries.map((entry) => { - let filePath = typeof entry.filePath === "string" ? normalizeProjectRelativePath(projectPath, entry.filePath) : entry.filePath; - if (filePath === entry.filePath) - return entry; - return { ...entry, filePath }; - }); -} function normalizeItems(items, projectPath, maxResults, includeDetails) { let seen = /* @__PURE__ */ new Set, normalized = []; for (let item of items) { - let rawPath = item[0]; - if (rawPath === "") + let rawPath = item.filePath; + if (!rawPath) continue; let normalizedPath = normalizeProjectRelativePath(projectPath, rawPath); if (!normalizedPath) continue; - let line = includeDetails && typeof item[1] === "number" ? item[1] : void 0, text = includeDetails && typeof item[2] === "string" ? item[2] : void 0, normalizedItem = line === void 0 ? [normalizedPath] : text === void 0 ? [normalizedPath, line] : [normalizedPath, line, text], key = JSON.stringify(normalizedItem); + let normalizedItem = { filePath: normalizedPath }; + if (includeDetails && typeof item.lineNumber === "number") { + if (normalizedItem.lineNumber = item.lineNumber, typeof item.lineText === "string") + normalizedItem.lineText = item.lineText; + } + let key = JSON.stringify(normalizedItem); if (seen.has(key)) continue; if (seen.add(key), normalized.push(normalizedItem), normalized.length >= maxResults) @@ -22712,7 +23548,12 @@ function normalizeItemsFromEntries(entries, projectPath, maxResults, includeDeta let normalizedPath = normalizeProjectRelativePath(projectPath, entry.filePath); if (!normalizedPath) continue; - let line = includeDetails && typeof entry.lineNumber === "number" ? entry.lineNumber : void 0, text = includeDetails && typeof entry.lineText === "string" ? entry.lineText : void 0, item = line === void 0 ? [normalizedPath] : text === void 0 ? [normalizedPath, line] : [normalizedPath, line, text], key = JSON.stringify(item); + let item = { filePath: normalizedPath }; + if (includeDetails && typeof entry.lineNumber === "number") { + if (item.lineNumber = entry.lineNumber, typeof entry.lineText === "string") + item.lineText = entry.lineText; + } + let key = JSON.stringify(item); if (seen.has(key)) continue; if (seen.add(key), items.push(item), items.length >= maxResults) @@ -22728,10 +23569,9 @@ function normalizeItemsFromFiles(files, projectPath, maxResults) { let normalizedPath = normalizeProjectRelativePath(projectPath, file2); if (!normalizedPath) continue; - let item = [normalizedPath]; if (seen.has(normalizedPath)) continue; - if (seen.add(normalizedPath), items.push(item), items.length >= maxResults) + if (seen.add(normalizedPath), items.push({ filePath: normalizedPath }), items.length >= maxResults) break; } return items; @@ -22746,41 +23586,338 @@ function resolveMoreFlag(result, itemCount, maxResults) { return !0; return itemCount >= maxResults; } -function filterEntriesByPath(entries, projectPath, relativePath, treatAsFile) { - if (!relativePath) - return entries; - let filter = createEntryPathFilter(projectPath, relativePath, treatAsFile); - return filter ? entries.filter(filter) : entries; +function normalizeProjectRelativePath(projectPath, filePath) { + if (!filePath) + return ""; + if (path6.isAbsolute(filePath)) { + let relative = path6.relative(projectPath, filePath); + if (!relative.startsWith("..") && !path6.isAbsolute(relative)) + return toPosixPath(relative); + return path6.normalize(filePath); + } + return toPosixPath(path6.normalize(filePath)); } -function createEntryPathFilter(projectPath, relativePath, treatAsFile) { - if (!relativePath) - return null; - let targetPath = path7.normalize(path7.resolve(projectPath, relativePath)); - if (treatAsFile) - return (entry) => normalizeEntryPath(projectPath, entry.filePath) === targetPath; - return (entry) => { - let entryPath = normalizeEntryPath(projectPath, entry.filePath); - if (typeof entryPath !== "string" || entryPath === "") - return !1; - return isWithinDirectory(entryPath, targetPath); +function toPosixPath(value) { + return value.replace(/\\/g, "/"); +} + +// proxy-tools/handlers/search-scope.ts +var import_picomatch = __toESM(require_picomatch2(), 1); +import path7 from "path"; +import { statSync } from "fs"; +function buildPathScope(projectPath, rawPaths) { + if (rawPaths === void 0 || rawPaths === null) + return { scope: null, normalizedPaths: null }; + if (!Array.isArray(rawPaths)) + throw Error("paths must be an array of strings"); + let normalizedEntries = []; + for (let entry of rawPaths) { + if (entry === void 0 || entry === null) + continue; + if (typeof entry !== "string") + throw Error("paths must be an array of strings"); + let normalized = normalizePattern(entry, projectPath); + if (normalized) + normalizedEntries.push(normalized); + } + if (normalizedEntries.length === 0) + return { scope: null, normalizedPaths: null }; + let normalizedPaths = normalizedEntries.map((entry) => entry.isExclude ? `!${entry.pattern}` : entry.pattern), includePatterns = normalizedEntries.filter((entry) => !entry.isExclude).map((entry) => entry.pattern), excludePatterns = normalizedEntries.filter((entry) => entry.isExclude).map((entry) => entry.pattern), effectiveIncludes = includePatterns.length > 0 ? includePatterns : ["**/*"], includeMatchers = effectiveIncludes.map(createMatcher), excludeMatchers = excludePatterns.map(createMatcher), commonDirectory = computeCommonDirectory(effectiveIncludes); + return { + scope: { + includeMatchers, + excludeMatchers, + commonDirectory + }, + normalizedPaths }; } +function normalizeGlobPattern(raw, projectPath, originalPattern = raw) { + let value = raw.trim(); + if (value === "") + throw Error("Glob pattern is empty"); + value = value.replace(/\\/g, "/"); + while (value.startsWith("./")) + value = value.slice(2); + if (value.endsWith("/")) + value = value.replace(/\/+$/, ""), value = value === "" ? "**" : `${value}/**`; + if (!value.includes("/")) + value = `**/${value}`; + let normalized = normalizePathPattern(value, projectPath, originalPattern); + if (normalized === "") + throw Error(`Invalid glob pattern: ${originalPattern}`); + return normalized; +} +function resolveSearchRoot(projectPath, scope, globPattern) { + let candidates = []; + if (scope?.commonDirectory) + candidates.push(scope.commonDirectory); + if (globPattern) { + let prefix = extractDirectoryPrefix(globPattern); + if (prefix) + candidates.push(prefix); + } + for (let candidate of candidates) { + if (!candidate) + continue; + let absolute = path7.resolve(projectPath, candidate); + if (isDirectory(absolute)) + return candidate; + } + return null; +} +function filterEntriesByScope(entries, projectPath, scope) { + return entries.filter((entry) => { + let relative = resolveRelativePath(projectPath, entry.filePath); + if (!relative) + return !1; + return matchesScope(scope, relative); + }); +} +function filterEntriesByDirectory(entries, projectPath, directoryToSearch) { + let absoluteDir = path7.resolve(projectPath, directoryToSearch); + return entries.filter((entry) => { + let absolutePath = resolveAbsolutePath(projectPath, entry.filePath); + return absolutePath ? isWithinDirectory(absolutePath, absoluteDir) : !1; + }); +} +function filterFilesByScope(files, projectPath, scope) { + return files.filter((filePath) => { + let relative = resolveRelativePath(projectPath, filePath); + if (!relative) + return !1; + return matchesScope(scope, relative); + }); +} +function expandLimit(limit, scope) { + if (!scope) + return limit; + return Math.min(limit * SEARCH_SCOPE_MULTIPLIER, MAX_RESULTS_UPPER_BOUND); +} +function normalizePattern(raw, projectPath) { + let value = raw.trim(); + if (value === "") + return null; + let isExclude = !1; + if (value.startsWith("!")) { + if (isExclude = !0, value = value.slice(1).trim(), value === "") + throw Error("Exclude pattern is empty"); + } + return { pattern: normalizeGlobPattern(value, projectPath, raw), isExclude }; +} +function normalizePathPattern(pattern, projectPath, originalPattern) { + let globIndex = indexOfGlobChar(pattern), prefix = globIndex < 0 ? pattern : pattern.slice(0, globIndex), prefixTrimmed = prefix.replace(/\/+$/, ""); + if (prefixTrimmed === "") { + if (isAbsolutePattern(pattern)) + throw Error(`Specified path '${originalPattern}' points outside the project directory`); + return pattern; + } + let absolutePrefix = path7.isAbsolute(prefixTrimmed) ? path7.normalize(prefixTrimmed) : path7.resolve(projectPath, prefixTrimmed); + if (!isWithinProject(projectPath, absolutePrefix)) + throw Error(`Specified path '${originalPattern}' points outside the project directory`); + let relativePrefix = toPosixPath2(path7.relative(projectPath, absolutePrefix)), suffix = pattern.slice(prefix.length).replace(/^\/+/, ""); + if (relativePrefix === "") + return suffix; + if (suffix === "") + return relativePrefix; + return `${relativePrefix}/${suffix}`; +} +function isAbsolutePattern(pattern) { + if (pattern.startsWith("/")) + return !0; + return /^[A-Za-z]:\//.test(pattern); +} +function indexOfGlobChar(pattern) { + for (let i = 0;i < pattern.length; i += 1) { + let value = pattern[i]; + if (value === "*" || value === "?" || value === "[" || value === "]" || value === "{" || value === "}") + return i; + } + return -1; +} +function computeCommonDirectory(patterns) { + let prefixes = patterns.map(extractDirectoryPrefix).filter((value) => Boolean(value)); + if (prefixes.length === 0) + return null; + let segments = prefixes.map((value) => value.split("/").filter(Boolean)), common = segments[0]; + for (let parts of segments.slice(1)) { + let max = Math.min(common.length, parts.length), index = 0; + while (index < max && common[index] === parts[index]) + index += 1; + if (index === 0) + return null; + common = common.slice(0, index); + } + if (common.length === 0) + return null; + return path7.normalize(common.join("/")); +} +function extractDirectoryPrefix(pattern) { + let globIndex = indexOfGlobChar(pattern), trimmed = (globIndex < 0 ? pattern : pattern.slice(0, globIndex)).replace(/\/+$/, ""); + if (trimmed === "") + return null; + if (globIndex < 0) { + let slashIndex = trimmed.lastIndexOf("/"); + if (slashIndex < 0) + return null; + let dir = trimmed.slice(0, slashIndex); + return dir === "" ? null : dir; + } + return trimmed; +} +function createMatcher(pattern) { + let nocase = path7.sep === "\\", matcher = import_picomatch.default(pattern, { dot: !0, nocase }); + return (candidate) => matcher(candidate); +} +function isDirectory(candidatePath) { + try { + return statSync(candidatePath).isDirectory(); + } catch { + return !1; + } +} +function resolveRelativePath(projectPath, filePath) { + let absolute = resolveAbsolutePath(projectPath, filePath); + if (!absolute) + return null; + let relative = path7.relative(projectPath, absolute); + if (relative.startsWith("..") || path7.isAbsolute(relative)) + return null; + return toPosixPath2(relative); +} +function resolveAbsolutePath(projectPath, filePath) { + let resolved = normalizeEntryPath(projectPath, filePath); + if (typeof resolved !== "string" || resolved === "") + return null; + return path7.normalize(resolved); +} +function matchesScope(scope, relativePosix) { + if (!scope.includeMatchers.some((matcher) => matcher(relativePosix))) + return !1; + return scope.excludeMatchers.every((matcher) => !matcher(relativePosix)); +} +function isWithinProject(projectPath, candidatePath) { + let relative = path7.relative(projectPath, candidatePath); + return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative); +} function isWithinDirectory(filePath, directoryPath) { let relative = path7.relative(directoryPath, filePath); - if (relative === "") - return !0; - return !relative.startsWith("..") && !path7.isAbsolute(relative); + return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative); } -function containsWhitespace(value) { - return /\s/.test(value); -} -function looksLikeGlob(value) { - return GLOB_CHARS_RE2.test(value); -} -function looksLikePath(value) { - return value.includes("/") || value.includes("\\"); +function toPosixPath2(value) { + return value.replace(/\\/g, "/"); } +// proxy-tools/handlers/search-text.ts +async function handleSearchTextTool(args, projectPath, callUpstreamTool, capabilities) { + let query = requireString(args.q, "q").trim(), limit = normalizeLimit(args.limit), { scope, normalizedPaths } = buildPathScope(projectPath, args.paths); + if (capabilities.hasSearchText) { + let result = await callUpstreamTool("search_text", { + q: query, + ...normalizedPaths ? { paths: normalizedPaths } : {}, + limit + }), items = normalizeItems(extractItems(result), projectPath, limit, !0), more = resolveMoreFlag(result, items.length, limit); + return serializeSearchResult({ items, more }); + } + if (!capabilities.supportsText) + throw Error("text search is not supported by this IDE version"); + return await searchTextLegacy(query, scope, limit, projectPath, callUpstreamTool); +} +async function handleSearchRegexTool(args, projectPath, callUpstreamTool, capabilities) { + let query = requireString(args.q, "q").trim(), limit = normalizeLimit(args.limit), { scope, normalizedPaths } = buildPathScope(projectPath, args.paths); + if (capabilities.hasSearchRegex) { + let result = await callUpstreamTool("search_regex", { + q: query, + ...normalizedPaths ? { paths: normalizedPaths } : {}, + limit + }), items = normalizeItems(extractItems(result), projectPath, limit, !0), more = resolveMoreFlag(result, items.length, limit); + return serializeSearchResult({ items, more }); + } + if (!capabilities.supportsRegex) + throw Error("regex search is not supported by this IDE version"); + return await searchRegexLegacy(query, scope, limit, projectPath, callUpstreamTool); +} +async function searchTextLegacy(query, scope, limit, projectPath, callUpstreamTool) { + let requestLimit = expandLimit(limit, scope), directoryToSearch = resolveSearchRoot(projectPath, scope, null), { entries, probablyHasMoreMatchingEntries, timedOut } = await searchInFiles({ + searchText: query, + directoryToSearch: directoryToSearch ?? void 0, + caseSensitive: !0, + maxUsageCount: requestLimit + }, callUpstreamTool), filtered = scope ? filterEntriesByScope(entries, projectPath, scope) : entries, items = normalizeItemsFromEntries(filtered, projectPath, limit, !0), more = timedOut || probablyHasMoreMatchingEntries || filtered.length > limit; + return serializeSearchResult({ items, more }); +} +async function searchRegexLegacy(query, scope, limit, projectPath, callUpstreamTool) { + let requestLimit = expandLimit(limit, scope), directoryToSearch = resolveSearchRoot(projectPath, scope, null), { entries, probablyHasMoreMatchingEntries, timedOut } = await searchInFiles({ + regexPattern: query, + directoryToSearch: directoryToSearch ?? void 0, + caseSensitive: !0, + maxUsageCount: requestLimit + }, callUpstreamTool), filtered = entries; + if (directoryToSearch && shouldApplyWorkaround("search_in_files_by_regex_directory_scope_ignored" /* SearchInFilesByRegexDirectoryScopeIgnored */)) + filtered = filterEntriesByDirectory(filtered, projectPath, directoryToSearch); + if (scope) + filtered = filterEntriesByScope(filtered, projectPath, scope); + let items = normalizeItemsFromEntries(filtered, projectPath, limit, !0), more = timedOut || probablyHasMoreMatchingEntries || filtered.length > limit; + return serializeSearchResult({ items, more }); +} +// proxy-tools/handlers/search-file.ts +async function handleSearchFileTool(args, projectPath, callUpstreamTool, capabilities) { + let query = requireString(args.q, "q").trim(), includeExcluded = resolveIncludeExcluded(args), limit = normalizeLimit(args.limit), { scope, normalizedPaths } = buildPathScope(projectPath, args.paths); + if (capabilities.hasSearchFile) { + let result = await callUpstreamTool("search_file", { + q: query, + ...normalizedPaths ? { paths: normalizedPaths } : {}, + ...includeExcluded ? { includeExcluded: !0 } : {}, + limit + }), items = normalizeItems(extractItems(result), projectPath, limit, !1), more = resolveMoreFlag(result, items.length, limit); + return serializeSearchResult({ items, more }); + } + if (!capabilities.supportsFile) + throw Error("file search is not supported by this IDE version"); + return await searchFilesLegacy(query, scope, includeExcluded, limit, projectPath, callUpstreamTool); +} +async function searchFilesLegacy(query, scope, includeExcluded, limit, projectPath, callUpstreamTool) { + let normalizedPattern = normalizeGlobPattern(query, projectPath), requestLimit = expandLimit(limit, scope), basePath = resolveSearchRoot(projectPath, scope, normalizedPattern), result = await findFilesByGlob(normalizedPattern, requestLimit, basePath, callUpstreamTool, includeExcluded), filtered = scope ? filterFilesByScope(result.files, projectPath, scope) : result.files, items = normalizeItemsFromFiles(filtered, projectPath, limit), more = result.timedOut || result.probablyHasMoreMatchingFiles || filtered.length > limit; + return serializeSearchResult({ items, more }); +} +async function findFilesByGlob(pattern, limit, basePath, callUpstreamTool, includeExcluded) { + let toolArgs = { globPattern: pattern, fileCountLimit: limit }; + if (basePath) + toolArgs.subDirectoryRelativePath = basePath; + if (includeExcluded) + toolArgs.addExcluded = !0; + let result = await callUpstreamTool("find_files_by_glob", toolArgs); + return extractFilesResult(result); +} +function resolveIncludeExcluded(args) { + let raw = args.includeExcluded; + if (raw === void 0 || raw === null) + return !1; + if (typeof raw !== "boolean") + throw Error("includeExcluded must be a boolean"); + return raw; +} +function extractFilesResult(result) { + let files = extractFileList(result), structured = extractStructuredContent(result), structuredRecord = structured && typeof structured === "object" ? structured : null; + return { + files, + probablyHasMoreMatchingFiles: structuredRecord?.probablyHasMoreMatchingFiles === !0, + timedOut: structuredRecord?.timedOut === !0 + }; +} +// proxy-tools/handlers/search-symbol.ts +async function handleSearchSymbolTool(args, projectPath, callUpstreamTool, capabilities) { + let query = requireString(args.q, "q").trim(), limit = normalizeLimit(args.limit), { normalizedPaths } = buildPathScope(projectPath, args.paths); + if (capabilities.hasSearchSymbol) { + let result = await callUpstreamTool("search_symbol", { + q: query, + ...normalizedPaths ? { paths: normalizedPaths } : {}, + limit + }), items = normalizeItems(extractItems(result), projectPath, limit, !0), more = resolveMoreFlag(result, items.length, limit); + return serializeSearchResult({ items, more }); + } + throw Error("symbol search is not supported by this IDE version"); +} // proxy-tools/handlers/write.ts import path8 from "path"; async function handleWriteTool(args, projectPath, callUpstreamTool) { @@ -22804,15 +23941,6 @@ function objectSchema(properties, required2) { additionalProperties: !1 }; } -function enumSchema(values, description) { - let unique = Array.from(new Set(values)).filter((value) => value), schema = { - type: "string", - description - }; - if (unique.length > 0) - schema.enum = unique; - return schema; -} function createReadSchema(includeIndentation) { let properties = { file_path: { @@ -22908,46 +24036,43 @@ function createListDirSchema() { } }, ["dir_path"]); } -function createSearchSchema(capabilities) { - let targetValues = ["auto"]; - if (capabilities.supportsSymbol) - targetValues.push("symbol"); - if (capabilities.supportsFile) - targetValues.push("file"); - if (capabilities.supportsText) - targetValues.push("text"); - let queryTypes = ["text"]; - if (capabilities.supportsRegex) - queryTypes.push("regex"); - if (capabilities.supportsFileGlob) - queryTypes.push("glob"); - let properties = { - query: { +function createSearchSchema(qDescription) { + return objectSchema({ + q: { type: "string", - description: "Search query text." + description: qDescription }, - target: enumSchema(targetValues, 'Search target: "auto" (default), "symbol", "file", or "text".'), - path: { - type: "string", - description: "Optional base directory (absolute or project-relative)." + paths: { + type: "array", + description: "Optional list of project-relative glob patterns (supports ! excludes).", + items: { + type: "string" + } }, - file_mask: { - type: "string", - description: 'Optional filename mask (e.g. "*.kt") for text searches.' - }, - case_sensitive: { - type: "boolean", - description: "Case-sensitive text search (default: true)." - }, - max_results: { + limit: { type: "number", description: "Maximum number of results to return." - }, - output: enumSchema(["entries", "files"], 'Output mode: "entries" (default for text/symbol) or "files" (default for file searches).') - }; - if (queryTypes.length > 1) - properties.query_type = enumSchema(queryTypes, 'Query type: "text" (default), "regex" (text searches), or "glob" (file searches).'); - return objectSchema(properties, ["query"]); + } + }, ["q"]); +} +function createSearchTextSchema() { + return createSearchSchema("Text substring to search for."); +} +function createSearchRegexSchema() { + return createSearchSchema("Regular expression pattern to search for."); +} +function createSearchFileSchema() { + let base = createSearchSchema("Glob pattern to match file paths."); + return objectSchema({ + ...base.properties, + includeExcluded: { + type: "boolean", + description: "Whether to include excluded/ignored files in results." + } + }, base.required); +} +function createSearchSymbolSchema() { + return createSearchSchema("Symbol query text (class, method, field, etc.)."); } function createApplyPatchSchema() { return objectSchema({ @@ -22978,17 +24103,14 @@ function createRenameSchema() { var TOOL_MODES = { CODEX: "codex", CC: "cc" -}, SEARCH_TOOL_MODES = { - AUTO: "auto", - SEARCH: "search", - LEGACY: "legacy" -}, BLOCKED_TOOL_NAMES = /* @__PURE__ */ new Set(["create_new_file", "execute_terminal_command", "grep", "find", "glob"]), EXTRA_REPLACED_TOOL_NAMES = [ +}, BLOCKED_TOOL_NAMES = /* @__PURE__ */ new Set(["create_new_file", "execute_terminal_command"]), EXTRA_REPLACED_TOOL_NAMES = [ "search_in_files_by_text", "search_in_files_by_regex", "find_files_by_glob", "find_files_by_name_keyword", + "search", "execute_terminal_command" -], RENAME_TOOL_DESCRIPTION = "Rename a symbol (class/function/variable/etc.) using IDE refactoring. Updates all references across the project; do not use edit/apply_patch for renames.", LEGACY_SEARCH_TOOL_DESCRIPTION = "PRIMARY PROJECT SEARCH. Use this tool first. Returns JSON {items:[[path,line?,text?]], more?}. File-backed results only; output=files returns [path], output=entries returns [path,line,text] when available."; +], RENAME_TOOL_DESCRIPTION = "Rename a symbol (class/function/variable/etc.) using IDE refactoring. Updates all references across the project; do not use edit/apply_patch for renames."; function resolveToolDescription(description, context) { return typeof description === "function" ? description(context) : description; } @@ -22999,9 +24121,6 @@ function resolveToolExpose(expose, context) { return expose(context); return expose !== !1; } -function shouldExposeLegacySearch({ searchCapabilities }) { - return searchCapabilities.mode === SEARCH_TOOL_MODES.LEGACY || !searchCapabilities.hasUpstreamSearch; -} function buildToolSpec(name, description, inputSchema, context) { return { name, @@ -23015,32 +24134,89 @@ var TOOL_VARIANTS = [ name: "read_file", description: "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes.", schemaFactory: () => createReadSchema(!0), - handlerFactory: ({ projectPath, callUpstreamTool }) => (args) => handleReadTool(args, projectPath, callUpstreamTool, { format: "numbered" }), - upstreamNames: ["get_file_text_by_path"] + handlerFactory: ({ projectPath, callUpstreamTool, readCapabilities }) => (args) => handleReadTool(args, projectPath, callUpstreamTool, readCapabilities, { format: "numbered" }), + upstreamNames: ["get_file_text_by_path"], + expose: ({ readCapabilities }) => !readCapabilities.hasReadFile }, { mode: TOOL_MODES.CC, name: "read", description: "Read a local file using absolute or project-relative paths. Returns raw text.", schemaFactory: () => createReadSchema(!1), - handlerFactory: ({ projectPath, callUpstreamTool }) => (args) => handleReadTool(args, projectPath, callUpstreamTool, { format: "raw" }), + handlerFactory: ({ projectPath, callUpstreamTool, readCapabilities }) => (args) => handleReadTool(args, projectPath, callUpstreamTool, readCapabilities, { format: "raw" }), upstreamNames: ["get_file_text_by_path"] }, { mode: TOOL_MODES.CODEX, - name: "search", - description: LEGACY_SEARCH_TOOL_DESCRIPTION, - schemaFactory: ({ searchCapabilities }) => createSearchSchema(searchCapabilities), - handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchTool(args, projectPath, callUpstreamTool, searchCapabilities), - expose: shouldExposeLegacySearch + name: "search_text", + description: "Search for a text substring in project files.", + schemaFactory: () => createSearchTextSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchTextTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_text"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchText && searchCapabilities.supportsText }, { mode: TOOL_MODES.CC, - name: "search", - description: LEGACY_SEARCH_TOOL_DESCRIPTION, - schemaFactory: ({ searchCapabilities }) => createSearchSchema(searchCapabilities), - handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchTool(args, projectPath, callUpstreamTool, searchCapabilities), - expose: shouldExposeLegacySearch + name: "search_text", + description: "Search for a text substring in project files.", + schemaFactory: () => createSearchTextSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchTextTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_text"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchText && searchCapabilities.supportsText + }, + { + mode: TOOL_MODES.CODEX, + name: "search_regex", + description: "Search for a regular expression in project files.", + schemaFactory: () => createSearchRegexSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchRegexTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_regex"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchRegex && searchCapabilities.supportsRegex + }, + { + mode: TOOL_MODES.CC, + name: "search_regex", + description: "Search for a regular expression in project files.", + schemaFactory: () => createSearchRegexSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchRegexTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_regex"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchRegex && searchCapabilities.supportsRegex + }, + { + mode: TOOL_MODES.CODEX, + name: "search_file", + description: "Search for files using a glob pattern.", + schemaFactory: () => createSearchFileSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchFileTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_file"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchFile && searchCapabilities.supportsFile + }, + { + mode: TOOL_MODES.CC, + name: "search_file", + description: "Search for files using a glob pattern.", + schemaFactory: () => createSearchFileSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchFileTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_file"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchFile && searchCapabilities.supportsFile + }, + { + mode: TOOL_MODES.CODEX, + name: "search_symbol", + description: "Search for symbols (classes, methods, fields) by name.", + schemaFactory: () => createSearchSymbolSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchSymbolTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_symbol"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchSymbol && searchCapabilities.supportsSymbol + }, + { + mode: TOOL_MODES.CC, + name: "search_symbol", + description: "Search for symbols (classes, methods, fields) by name.", + schemaFactory: () => createSearchSymbolSchema(), + handlerFactory: ({ projectPath, callUpstreamTool, searchCapabilities }) => (args) => handleSearchSymbolTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ["search_symbol"], + expose: ({ searchCapabilities }) => !searchCapabilities.hasSearchSymbol && searchCapabilities.supportsSymbol }, { mode: TOOL_MODES.CODEX, @@ -23112,8 +24288,11 @@ function getReplacedToolNames() { for (let tool of TOOL_VARIANTS) { if (!tool.upstreamNames) continue; - for (let name of tool.upstreamNames) + for (let name of tool.upstreamNames) { + if (name === tool.name) + continue; replaced.add(name); + } } return replaced; } @@ -23132,55 +24311,54 @@ function resolveToolMode(rawValue) { warning: `Unknown JETBRAINS_MCP_TOOL_MODE '${rawValue}', defaulting to codex.` }; } -function resolveSearchToolMode(rawValue) { - if (rawValue === void 0 || rawValue === null || rawValue === "") - return { mode: SEARCH_TOOL_MODES.AUTO }; - let normalized = String(rawValue).trim().toLowerCase(); - if (normalized === "" || normalized === SEARCH_TOOL_MODES.AUTO) - return { mode: SEARCH_TOOL_MODES.AUTO }; - if (normalized === SEARCH_TOOL_MODES.LEGACY || normalized === "legacy" || normalized === "grep" || normalized === "false" || normalized === "0") - return { mode: SEARCH_TOOL_MODES.LEGACY }; - if (normalized === SEARCH_TOOL_MODES.SEARCH || normalized === "true" || normalized === "1" || normalized === "semantic") - return { mode: SEARCH_TOOL_MODES.SEARCH }; - return { - mode: SEARCH_TOOL_MODES.AUTO, - warning: `Unknown JETBRAINS_MCP_SEARCH_TOOL '${rawValue}', defaulting to auto.` - }; +var DISABLE_NEW_SEARCH_ENV = "JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH"; +function isEnvFlagEnabled(name) { + let raw = process.env[name]; + if (!raw) + return !1; + let normalized = raw.trim().toLowerCase(); + return normalized !== "" && normalized !== "0" && normalized !== "false"; } -function resolveSearchCapabilities(modeInfo, upstreamTools) { +function resolveSearchCapabilities(upstreamTools) { let names = /* @__PURE__ */ new Set; for (let tool of upstreamTools ?? []) { let name = typeof tool?.name === "string" ? tool.name : ""; if (name) names.add(name); } - let hasToolInfo = (upstreamTools ?? []).length > 0, hasUpstreamSearch = names.has("search"), supportsRegex = hasToolInfo ? names.has("search_in_files_by_regex") : !0, supportsText = hasToolInfo ? names.has("search_in_files_by_text") || supportsRegex : !0, supportsFileGlob = hasToolInfo ? names.has("find_files_by_glob") : !0, supportsFileName = hasToolInfo ? names.has("find_files_by_name_keyword") : !0, supportsFile = supportsFileGlob || supportsFileName, supportsSymbol = modeInfo.mode !== SEARCH_TOOL_MODES.LEGACY && hasUpstreamSearch, capabilities = { - mode: modeInfo.mode, - hasUpstreamSearch, - supportsSymbol, + let disableNewSearch = isEnvFlagEnabled(DISABLE_NEW_SEARCH_ENV), hasToolInfo = (upstreamTools ?? []).length > 0, hasSearchText = !disableNewSearch && names.has("search_text"), hasSearchRegex = !disableNewSearch && names.has("search_regex"), hasSearchFile = !disableNewSearch && names.has("search_file"), hasSearchSymbol = names.has("search_symbol"), supportsText = hasSearchText || (hasToolInfo ? names.has("search_in_files_by_text") : !0), supportsRegex = hasSearchRegex || (hasToolInfo ? names.has("search_in_files_by_regex") : !0), supportsFile = hasSearchFile || (hasToolInfo ? names.has("find_files_by_glob") : !0); + return { capabilities: { + hasSearchText, + hasSearchRegex, + hasSearchFile, + hasSearchSymbol, + supportsSymbol: hasSearchSymbol, supportsText, supportsRegex, - supportsFile, - supportsFileGlob, - supportsFileName - }; - if (modeInfo.mode === SEARCH_TOOL_MODES.SEARCH && hasToolInfo && !hasUpstreamSearch) - return { - capabilities, - warning: "JETBRAINS_MCP_SEARCH_TOOL=search requested, but upstream search is unavailable; falling back to legacy search." - }; - return { capabilities }; + supportsFile + } }; +} +function resolveReadCapabilities(upstreamTools) { + let names = /* @__PURE__ */ new Set; + for (let tool of upstreamTools ?? []) { + let name = typeof tool?.name === "string" ? tool.name : ""; + if (name) + names.add(name); + } + return { capabilities: { hasReadFile: names.has("read_file") } }; } function createProxyTooling({ projectPath, callUpstreamTool, toolMode, - searchCapabilities + searchCapabilities, + readCapabilities }) { let resolvedMode = toolMode === TOOL_MODES.CC ? TOOL_MODES.CC : TOOL_MODES.CODEX, { proxyToolSpecs, proxyToolNames, handlers } = buildProxyToolingData(resolvedMode, { projectPath, callUpstreamTool, - searchCapabilities + searchCapabilities, + readCapabilities }); async function runProxyToolCall(toolName, args) { let handler = handlers.get(toolName); @@ -23217,21 +24395,13 @@ function parseEnvSeconds(name, fallbackSeconds) { function buildStreamUrl(port) { return `http://${defaultHost}:${port}${defaultPath}`; } -var explicitProjectPath = env.JETBRAINS_MCP_PROJECT_PATH, projectPath = explicitProjectPath && explicitProjectPath.length > 0 ? path9.resolve(explicitProjectPath) : path9.resolve(cwd()), defaultProjectPathKey = "project_path", projectPathManager = createProjectPathManager({ projectPath, defaultProjectPathKey }), toolModeInfo = resolveToolMode(env.JETBRAINS_MCP_TOOL_MODE), REPLACED_TOOL_NAMES = getReplacedToolNames(), BASE_BLOCKED_TOOL_NAMES = /* @__PURE__ */ new Set([...BLOCKED_TOOL_NAMES, ...REPLACED_TOOL_NAMES]), searchToolModeInfo = resolveSearchToolMode(env.JETBRAINS_MCP_SEARCH_TOOL); -if (searchToolModeInfo.warning) - warn(searchToolModeInfo.warning); -var searchCapabilities = resolveSearchCapabilities(searchToolModeInfo, []).capabilities; -function buildBlockedToolNames() { - return new Set(BASE_BLOCKED_TOOL_NAMES); -} +var explicitProjectPath = env.JETBRAINS_MCP_PROJECT_PATH, projectPath = explicitProjectPath && explicitProjectPath.length > 0 ? path9.resolve(explicitProjectPath) : path9.resolve(cwd()), defaultProjectPathKey = "project_path", projectPathManager = createProjectPathManager({ projectPath, defaultProjectPathKey }), toolModeInfo = resolveToolMode(env.JETBRAINS_MCP_TOOL_MODE), REPLACED_TOOL_NAMES = getReplacedToolNames(), BASE_BLOCKED_TOOL_NAMES = /* @__PURE__ */ new Set([...BLOCKED_TOOL_NAMES, ...REPLACED_TOOL_NAMES]), searchCapabilities = resolveSearchCapabilities([]).capabilities, readCapabilities = resolveReadCapabilities([]).capabilities; function blockedToolMessage(toolName) { if (toolName === "create_new_file") { if (toolModeInfo.mode === TOOL_MODES.CC) return `Tool '${toolName}' is not exposed by ij-proxy. Use 'write' instead.`; return `Tool '${toolName}' is not exposed by ij-proxy. Use 'apply_patch' instead.`; } - if (toolName === "grep" || toolName === "find" || toolName === "glob") - return `Tool '${toolName}' is not exposed by ij-proxy. Use 'search' instead.`; return `Tool '${toolName}' is not exposed by ij-proxy.`; } var proxyToolSpecs = [], proxyToolNames = /* @__PURE__ */ new Set, runProxyToolCall = async () => { @@ -23242,7 +24412,8 @@ function updateProxyTooling() { projectPath, callUpstreamTool, toolMode: toolModeInfo.mode, - searchCapabilities + searchCapabilities, + readCapabilities }); proxyToolSpecs = tooling.proxyToolSpecs, proxyToolNames = tooling.proxyToolNames, runProxyToolCall = tooling.runProxyToolCall; } @@ -23274,6 +24445,9 @@ var streamTransport = createStreamTransport({ upstreamClient.onerror = (error48) => { warn(`Upstream client error: ${error48.message}`); }; +upstreamClient.onclose = () => { + resetUpstreamState(), warn("Upstream client connection closed; will reconnect on next request"); +}; var proxyServer = new Server({ name: "ij-mcp-proxy", version: "1.0.0" }, { capabilities: { tools: { listChanged: !0 }, @@ -23283,16 +24457,16 @@ var proxyServer = new Server({ name: "ij-mcp-proxy", version: "1.0.0" }, { } }); proxyServer.setRequestHandler(ListToolsRequestSchema, async () => { - let upstreamTools = await getUpstreamTools(), blocked = buildBlockedToolNames(); + let upstreamTools = await getUpstreamTools(); return { - tools: mergeToolLists(proxyToolSpecs, upstreamTools, blocked) + tools: mergeToolLists(proxyToolSpecs, upstreamTools, BASE_BLOCKED_TOOL_NAMES) }; }); proxyServer.setRequestHandler(CallToolRequestSchema, async (request) => { let toolName = typeof request.params?.name === "string" ? request.params.name : "", rawArgs = request.params?.arguments, args = rawArgs && typeof rawArgs === "object" ? { ...rawArgs } : {}; if (!toolName) return makeToolError("Tool name is required"); - if (buildBlockedToolNames().has(toolName)) + if (BASE_BLOCKED_TOOL_NAMES.has(toolName)) return makeToolError(blockedToolMessage(toolName)); if (proxyToolNames.has(toolName)) try { @@ -23310,10 +24484,14 @@ proxyServer.setRequestHandler(CallToolRequestSchema, async (request) => { } }); proxyServer.fallbackRequestHandler = async (request) => { - return await ensureUpstreamConnected(), await upstreamClient.request({ method: request.method, params: request.params }, ResultSchema); + return await withUpstreamReconnect(request.method, async () => { + return await ensureUpstreamConnected(), await upstreamClient.request({ method: request.method, params: request.params }, ResultSchema); + }); }; proxyServer.fallbackNotificationHandler = async (notification) => { - await ensureUpstreamConnected(), await upstreamClient.notification(notification); + await withUpstreamReconnect(notification.method, async () => { + await ensureUpstreamConnected(), await upstreamClient.notification(notification); + }); }; upstreamClient.setNotificationHandler(ToolListChangedNotificationSchema, async () => { try { @@ -23342,8 +24520,35 @@ proxyServer.connect(stdioTransport).catch((error48) => { let message = error48 instanceof Error ? error48.message : String(error48); warn(`Failed to start stdio transport: ${message}`); }); -var upstreamConnectedPromise = null, upstreamTools = null; +var upstreamConnectedPromise = null, upstreamTools = null, RECOVERABLE_UPSTREAM_ERROR_RE = /\b(not connected|connection closed|session not found|server not initialized|mcp-session-id header is required)\b/i; +function getErrorMessage(error48) { + return error48 instanceof Error ? error48.message : String(error48); +} +function isRecoverableUpstreamError(error48) { + let message = getErrorMessage(error48); + return RECOVERABLE_UPSTREAM_ERROR_RE.test(message); +} +function resetUpstreamState() { + upstreamConnectedPromise = null, upstreamTools = null, searchCapabilities = resolveSearchCapabilities([]).capabilities, readCapabilities = resolveReadCapabilities([]).capabilities, updateProxyTooling(), setIdeVersion(null); +} +async function withUpstreamReconnect(label, fn) { + try { + return await fn(); + } catch (error48) { + if (!isRecoverableUpstreamError(error48)) + throw error48; + warn(`Upstream ${label} failed (${getErrorMessage(error48)}); reconnecting and retrying once`), resetUpstreamState(); + try { + await streamTransport.resetTransport(error48); + } catch (resetError) { + warn(`Failed to reset MCP stream transport: ${getErrorMessage(resetError)}`); + } + return await ensureUpstreamConnected(), fn(); + } +} async function ensureUpstreamConnected() { + if (!upstreamClient.transport) + upstreamConnectedPromise = null, upstreamTools = null; if (upstreamConnectedPromise) return upstreamConnectedPromise; return upstreamConnectedPromise = upstreamClient.connect(streamTransport).catch((error48) => { @@ -23357,13 +24562,11 @@ function updateIdeVersionFromUpstream() { setIdeVersion(typeof version2 === "string" ? version2 : null); } async function refreshUpstreamTools() { - await ensureUpstreamConnected(); - let response = await upstreamClient.listTools(), tools = Array.isArray(response?.tools) ? response.tools : []; - projectPathManager.updateProjectPathKeys(tools), projectPathManager.stripProjectPathFromTools(tools), upstreamTools = tools; - let resolvedCapabilities = resolveSearchCapabilities(searchToolModeInfo, tools); - if (resolvedCapabilities.warning) - warn(resolvedCapabilities.warning); - return searchCapabilities = resolvedCapabilities.capabilities, updateProxyTooling(), tools; + return await withUpstreamReconnect("tools/list", async () => { + await ensureUpstreamConnected(); + let response = await upstreamClient.listTools(), tools = Array.isArray(response?.tools) ? response.tools : []; + return projectPathManager.updateProjectPathKeys(tools), projectPathManager.stripProjectPathFromTools(tools), upstreamTools = tools, searchCapabilities = resolveSearchCapabilities(tools).capabilities, readCapabilities = resolveReadCapabilities(tools).capabilities, updateProxyTooling(), tools; + }); } async function getUpstreamTools() { if (!upstreamTools) @@ -23397,21 +24600,25 @@ function makeToolError(text) { }; } async function callUpstreamToolForClient(toolName, args) { - await ensureUpstreamConnected(), await getUpstreamTools(), projectPathManager.injectProjectPathArgs(toolName, args); - let options = toolCallTimeoutMs > 0 ? { timeout: toolCallTimeoutMs } : void 0, result = await upstreamClient.callTool({ name: toolName, arguments: args }, void 0, options); - return normalizeToolResult(result); + return await withUpstreamReconnect(`tools/call ${toolName}`, async () => { + await ensureUpstreamConnected(), await getUpstreamTools(), projectPathManager.injectProjectPathArgs(toolName, args); + let options = toolCallTimeoutMs > 0 ? { timeout: toolCallTimeoutMs } : void 0, result = await upstreamClient.callTool({ name: toolName, arguments: args }, void 0, options); + return normalizeToolResult(result); + }); } async function callUpstreamTool(toolName, args) { - await ensureUpstreamConnected(), await getUpstreamTools(); - let callArgs = { ...args }; - projectPathManager.injectProjectPathArgs(toolName, callArgs); - let options = toolCallTimeoutMs > 0 ? { timeout: toolCallTimeoutMs } : void 0, result = normalizeToolResult(await upstreamClient.callTool({ name: toolName, arguments: callArgs }, void 0, options)); - if (result?.isError) - throw Error(extractTextFromResult(result) || "Upstream tool error"); - return result; + return await withUpstreamReconnect(`tools/call ${toolName}`, async () => { + await ensureUpstreamConnected(), await getUpstreamTools(); + let callArgs = { ...args }; + projectPathManager.injectProjectPathArgs(toolName, callArgs); + let options = toolCallTimeoutMs > 0 ? { timeout: toolCallTimeoutMs } : void 0, result = normalizeToolResult(await upstreamClient.callTool({ name: toolName, arguments: callArgs }, void 0, options)); + if (result?.isError) + throw Error(extractTextFromResult(result) || "Upstream tool error"); + return result; + }); } function mergeToolLists(proxyTools, upstreamTools2, blockedNames) { - let blocked = new Set(blockedNames || []), result = [], seen = /* @__PURE__ */ new Set; + let blocked = blockedNames instanceof Set ? blockedNames : new Set(blockedNames || []), result = [], seen = /* @__PURE__ */ new Set; for (let tool of proxyTools || []) { if (!tool || typeof tool.name !== "string") continue; diff --git a/build/mcp-servers/ij-proxy/ij-mcp-proxy.ts b/build/mcp-servers/ij-proxy/ij-mcp-proxy.ts index 71eb2910a7a1..c7ebc539aad0 100644 --- a/build/mcp-servers/ij-proxy/ij-mcp-proxy.ts +++ b/build/mcp-servers/ij-proxy/ij-mcp-proxy.ts @@ -15,8 +15,8 @@ import {createProjectPathManager} from './project-path' import {createStreamTransport} from './stream-transport' import {setIdeVersion} from './workarounds' import {BLOCKED_TOOL_NAMES, getReplacedToolNames} from './proxy-tools/registry' -import type {SearchToolModeInfo, ToolModeInfo} from './proxy-tools/tooling' -import {createProxyTooling, resolveSearchCapabilities, resolveSearchToolMode, resolveToolMode, TOOL_MODES} from './proxy-tools/tooling' +import type {ToolModeInfo} from './proxy-tools/tooling' +import {createProxyTooling, resolveReadCapabilities, resolveSearchCapabilities, resolveToolMode, TOOL_MODES} from './proxy-tools/tooling' import {extractTextFromResult} from './proxy-tools/shared' import type {ToolArgs, ToolResultLike, ToolSpecLike} from './proxy-tools/types' @@ -85,17 +85,8 @@ const projectPathManager = createProjectPathManager({projectPath, defaultProject const toolModeInfo: ToolModeInfo = resolveToolMode(env.JETBRAINS_MCP_TOOL_MODE) const REPLACED_TOOL_NAMES = getReplacedToolNames() const BASE_BLOCKED_TOOL_NAMES = new Set([...BLOCKED_TOOL_NAMES, ...REPLACED_TOOL_NAMES]) -const searchToolModeInfo: SearchToolModeInfo = resolveSearchToolMode(env.JETBRAINS_MCP_SEARCH_TOOL) - -if (searchToolModeInfo.warning) { - warn(searchToolModeInfo.warning) -} - -let searchCapabilities = resolveSearchCapabilities(searchToolModeInfo, []).capabilities - -function buildBlockedToolNames(): Set { - return new Set(BASE_BLOCKED_TOOL_NAMES) -} +let searchCapabilities = resolveSearchCapabilities([]).capabilities +let readCapabilities = resolveReadCapabilities([]).capabilities function blockedToolMessage(toolName: string): string { if (toolName === 'create_new_file') { @@ -104,9 +95,6 @@ function blockedToolMessage(toolName: string): string { } return `Tool '${toolName}' is not exposed by ij-proxy. Use 'apply_patch' instead.` } - if (toolName === 'grep' || toolName === 'find' || toolName === 'glob') { - return `Tool '${toolName}' is not exposed by ij-proxy. Use 'search' instead.` - } return `Tool '${toolName}' is not exposed by ij-proxy.` } @@ -121,7 +109,8 @@ function updateProxyTooling(): void { projectPath, callUpstreamTool, toolMode: toolModeInfo.mode, - searchCapabilities + searchCapabilities, + readCapabilities }) proxyToolSpecs = tooling.proxyToolSpecs proxyToolNames = tooling.proxyToolNames @@ -167,6 +156,10 @@ const upstreamClient = new Client({name: 'ij-mcp-proxy', version: '1.0.0'}) upstreamClient.onerror = (error) => { warn(`Upstream client error: ${error.message}`) } +upstreamClient.onclose = () => { + resetUpstreamState() + warn('Upstream client connection closed; will reconnect on next request') +} const proxyServer = new Server( {name: 'ij-mcp-proxy', version: '1.0.0'}, @@ -182,9 +175,8 @@ const proxyServer = new Server( proxyServer.setRequestHandler(ListToolsRequestSchema, async () => { const upstreamTools = await getUpstreamTools() - const blocked = buildBlockedToolNames() return { - tools: mergeToolLists(proxyToolSpecs, upstreamTools, blocked) + tools: mergeToolLists(proxyToolSpecs, upstreamTools, BASE_BLOCKED_TOOL_NAMES) } }) @@ -199,7 +191,7 @@ proxyServer.setRequestHandler(CallToolRequestSchema, async (request) => { return makeToolError('Tool name is required') } - if (buildBlockedToolNames().has(toolName)) { + if (BASE_BLOCKED_TOOL_NAMES.has(toolName)) { return makeToolError(blockedToolMessage(toolName)) } @@ -222,13 +214,17 @@ proxyServer.setRequestHandler(CallToolRequestSchema, async (request) => { }) proxyServer.fallbackRequestHandler = async (request) => { - await ensureUpstreamConnected() - return await upstreamClient.request({method: request.method, params: request.params}, ResultSchema) + return await withUpstreamReconnect(request.method, async () => { + await ensureUpstreamConnected() + return await upstreamClient.request({method: request.method, params: request.params}, ResultSchema) + }) } proxyServer.fallbackNotificationHandler = async (notification) => { - await ensureUpstreamConnected() - await upstreamClient.notification(notification) + await withUpstreamReconnect(notification.method, async () => { + await ensureUpstreamConnected() + await upstreamClient.notification(notification) + }) } upstreamClient.setNotificationHandler(ToolListChangedNotificationSchema, async () => { @@ -266,7 +262,48 @@ void proxyServer.connect(stdioTransport).catch((error) => { let upstreamConnectedPromise: Promise | null = null let upstreamTools: ToolSpecLike[] | null = null +const RECOVERABLE_UPSTREAM_ERROR_RE = /\b(not connected|connection closed|session not found|server not initialized|mcp-session-id header is required)\b/i + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isRecoverableUpstreamError(error: unknown): boolean { + const message = getErrorMessage(error) + return RECOVERABLE_UPSTREAM_ERROR_RE.test(message) +} + +function resetUpstreamState(): void { + upstreamConnectedPromise = null + upstreamTools = null + searchCapabilities = resolveSearchCapabilities([]).capabilities + readCapabilities = resolveReadCapabilities([]).capabilities + updateProxyTooling() + setIdeVersion(null) +} + +async function withUpstreamReconnect(label: string, fn: () => Promise): Promise { + try { + return await fn() + } catch (error) { + if (!isRecoverableUpstreamError(error)) throw error + warn(`Upstream ${label} failed (${getErrorMessage(error)}); reconnecting and retrying once`) + resetUpstreamState() + try { + await streamTransport.resetTransport(error) + } catch (resetError) { + warn(`Failed to reset MCP stream transport: ${getErrorMessage(resetError)}`) + } + await ensureUpstreamConnected() + return fn() + } +} + async function ensureUpstreamConnected(): Promise { + if (!upstreamClient.transport) { + upstreamConnectedPromise = null + upstreamTools = null + } if (upstreamConnectedPromise) return upstreamConnectedPromise upstreamConnectedPromise = upstreamClient.connect(streamTransport).catch((error) => { upstreamConnectedPromise = null @@ -285,19 +322,18 @@ function updateIdeVersionFromUpstream(): void { } async function refreshUpstreamTools(): Promise { - await ensureUpstreamConnected() - const response = await upstreamClient.listTools() - const tools = Array.isArray(response?.tools) ? response.tools : [] - projectPathManager.updateProjectPathKeys(tools) - projectPathManager.stripProjectPathFromTools(tools) - upstreamTools = tools - const resolvedCapabilities = resolveSearchCapabilities(searchToolModeInfo, tools) - if (resolvedCapabilities.warning) { - warn(resolvedCapabilities.warning) - } - searchCapabilities = resolvedCapabilities.capabilities - updateProxyTooling() - return tools + return await withUpstreamReconnect('tools/list', async () => { + await ensureUpstreamConnected() + const response = await upstreamClient.listTools() + const tools = Array.isArray(response?.tools) ? response.tools : [] + projectPathManager.updateProjectPathKeys(tools) + projectPathManager.stripProjectPathFromTools(tools) + upstreamTools = tools + searchCapabilities = resolveSearchCapabilities(tools).capabilities + readCapabilities = resolveReadCapabilities(tools).capabilities + updateProxyTooling() + return tools + }) } async function getUpstreamTools(): Promise { @@ -338,36 +374,40 @@ function makeToolError(text: unknown): ToolOutput { } async function callUpstreamToolForClient(toolName: string, args: ToolArgs): Promise { - await ensureUpstreamConnected() - await getUpstreamTools() - projectPathManager.injectProjectPathArgs(toolName, args) - const options = toolCallTimeoutMs > 0 ? {timeout: toolCallTimeoutMs} : undefined - const result = await upstreamClient.callTool({name: toolName, arguments: args}, undefined, options) - return normalizeToolResult(result) + return await withUpstreamReconnect(`tools/call ${toolName}`, async () => { + await ensureUpstreamConnected() + await getUpstreamTools() + projectPathManager.injectProjectPathArgs(toolName, args) + const options = toolCallTimeoutMs > 0 ? {timeout: toolCallTimeoutMs} : undefined + const result = await upstreamClient.callTool({name: toolName, arguments: args}, undefined, options) + return normalizeToolResult(result) + }) } async function callUpstreamTool(toolName: string, args: ToolArgs): Promise { - await ensureUpstreamConnected() - await getUpstreamTools() - const callArgs = {...args} - projectPathManager.injectProjectPathArgs(toolName, callArgs) - const options = toolCallTimeoutMs > 0 ? {timeout: toolCallTimeoutMs} : undefined - const result = normalizeToolResult( - await upstreamClient.callTool({name: toolName, arguments: callArgs}, undefined, options) - ) + return await withUpstreamReconnect(`tools/call ${toolName}`, async () => { + await ensureUpstreamConnected() + await getUpstreamTools() + const callArgs = {...args} + projectPathManager.injectProjectPathArgs(toolName, callArgs) + const options = toolCallTimeoutMs > 0 ? {timeout: toolCallTimeoutMs} : undefined + const result = normalizeToolResult( + await upstreamClient.callTool({name: toolName, arguments: callArgs}, undefined, options) + ) - if (result?.isError) { - throw new Error(extractTextFromResult(result) || 'Upstream tool error') - } - return result + if (result?.isError) { + throw new Error(extractTextFromResult(result) || 'Upstream tool error') + } + return result + }) } function mergeToolLists( proxyTools: ToolSpecLike[] | undefined, upstreamTools: ToolSpecLike[] | undefined, - blockedNames: Iterable + blockedNames: Set | Iterable | undefined ): ToolSpecLike[] { - const blocked = new Set(blockedNames || []) + const blocked = blockedNames instanceof Set ? blockedNames : new Set(blockedNames || []) const result: ToolSpecLike[] = [] const seen = new Set() diff --git a/build/mcp-servers/ij-proxy/integration-tests/jb-mcp-proxy-fallback.test.ts b/build/mcp-servers/ij-proxy/integration-tests/jb-mcp-proxy-fallback.test.ts new file mode 100644 index 000000000000..1fd4f3a5ecf2 --- /dev/null +++ b/build/mcp-servers/ij-proxy/integration-tests/jb-mcp-proxy-fallback.test.ts @@ -0,0 +1,80 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {expect, test} from 'bun:test' +import {extractItems} from '../proxy-tools/shared' +import {withStreamProxy} from '../test-utils' +import {dirAAbs, dirARel, isUnder, projectRoot, REGEX_SCOPE_PATTERN, streamUrl, toAbsolute} from './jb-mcp-test-utils' + +const maybeTest = streamUrl ? test : test.skip + +function extractFilePaths(result: unknown): string[] { + return extractItems(result) + .map((item) => item.filePath) + .filter((filePath) => filePath.length > 0) + .map(toAbsolute) +} + +maybeTest('ij proxy fallback search tools honor path scopes', async () => { + if (!streamUrl) return + + await withStreamProxy({ + proxyEnv: { + JETBRAINS_MCP_STREAM_URL: streamUrl, + JETBRAINS_MCP_PROJECT_PATH: projectRoot, + JETBRAINS_MCP_TOOL_MODE: 'cc', + JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH: 'true' + } + }, async ({proxyClient}) => { + const listResponse = await proxyClient.send('tools/list') + const names = listResponse.result.tools.map((tool) => tool.name) + if (!names.includes('search_text') && !names.includes('search_regex') && !names.includes('search_file')) return + + if (names.includes('search_text')) { + const response = await proxyClient.send('tools/call', { + name: 'search_text', + arguments: { + q: REGEX_SCOPE_PATTERN, + paths: [`${dirARel}/`], + limit: 20 + } + }) + + const filePaths = extractFilePaths(response.result) + + expect(filePaths.length).toBeGreaterThan(0) + expect(filePaths.every((filePath) => isUnder(dirAAbs, filePath))).toBe(true) + } + + if (names.includes('search_regex')) { + const response = await proxyClient.send('tools/call', { + name: 'search_regex', + arguments: { + q: REGEX_SCOPE_PATTERN, + paths: [`${dirARel}/`], + limit: 20 + } + }) + + const filePaths = extractFilePaths(response.result) + + expect(filePaths.length).toBeGreaterThan(0) + expect(filePaths.every((filePath) => isUnder(dirAAbs, filePath))).toBe(true) + } + + if (names.includes('search_file')) { + const response = await proxyClient.send('tools/call', { + name: 'search_file', + arguments: { + q: '*.txt', + paths: [`${dirARel}/`], + limit: 20 + } + }) + + const filePaths = extractFilePaths(response.result) + + expect(filePaths.length).toBeGreaterThan(0) + expect(filePaths.every((filePath) => isUnder(dirAAbs, filePath))).toBe(true) + } + }) +}) diff --git a/build/mcp-servers/ij-proxy/integration-tests/jb-mcp-search.tool.test.ts b/build/mcp-servers/ij-proxy/integration-tests/jb-mcp-search.tool.test.ts index d713378125ba..13c950188d9b 100644 --- a/build/mcp-servers/ij-proxy/integration-tests/jb-mcp-search.tool.test.ts +++ b/build/mcp-servers/ij-proxy/integration-tests/jb-mcp-search.tool.test.ts @@ -9,7 +9,7 @@ import {dirAAbs, dirARel, isUnder, projectRoot, REGEX_SCOPE_PATTERN, streamUrl, const maybeTest = streamUrl ? test : test.skip -maybeTest('jb mcp search tool supports lexical text provider', async () => { +maybeTest('jb mcp search_text tool supports path filtering', async () => { if (!streamUrl) return const client = new Client({name: 'ij-mcp-proxy-integration-test', version: '1.0.0'}) @@ -26,20 +26,15 @@ maybeTest('jb mcp search tool supports lexical text provider', async () => { try { const toolList = await client.listTools() const hasSearch = Array.isArray(toolList?.tools) - && toolList.tools.some((tool) => tool?.name === 'search') + && toolList.tools.some((tool) => tool?.name === 'search_text') if (!hasSearch) return const result = await client.callTool({ - name: 'search', + name: 'search_text', arguments: { - query: REGEX_SCOPE_PATTERN, - mode: 'lexical', - providers: ['text'], - directoryToSearch: dirARel, - fileMask: '*.txt', - queryType: 'text', - maxResults: 20, - output: 'entries' + q: REGEX_SCOPE_PATTERN, + paths: [`${dirARel}/`], + limit: 20 } }) @@ -47,8 +42,8 @@ maybeTest('jb mcp search tool supports lexical text provider', async () => { expect(items.length).toBeGreaterThan(0) const filePaths = items - .map((item) => item[0]) - .filter((filePath) => filePath.length > 0) + .map((item) => item.filePath) + .filter((filePath) => typeof filePath === 'string' && filePath.length > 0) .map(toAbsolute) expect(filePaths.some((filePath) => isUnder(dirAAbs, filePath))).toBe(true) diff --git a/build/mcp-servers/ij-proxy/integration-tests/reconnect.test.ts b/build/mcp-servers/ij-proxy/integration-tests/reconnect.test.ts new file mode 100644 index 000000000000..cffe8dfbdb4a --- /dev/null +++ b/build/mcp-servers/ij-proxy/integration-tests/reconnect.test.ts @@ -0,0 +1,45 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {ok} from 'node:assert/strict' +import {describe, it} from 'bun:test' +import {buildUpstreamTool, startFakeMcpServer, SUITE_TIMEOUT_MS, withProxy} from '../test-utils' + +describe('ij MCP proxy reconnect', {timeout: SUITE_TIMEOUT_MS}, () => { + it('reconnects when upstream session is invalidated (server restart)', async () => { + const tools = [buildUpstreamTool('upstream_echo', {value: {type: 'string'}}, ['value'])] + const onToolCall = (call: {args: unknown}) => ({text: JSON.stringify(call.args)}) + + await withProxy({tools, onToolCall}, async ({fakeServer, proxyClient}) => { + await proxyClient.send('tools/list') + + const first = await proxyClient.send('tools/call', { + name: 'upstream_echo', + arguments: {value: 'one'} + }) + ok(!first.result?.isError, first.result?.content?.[0]?.text ?? '') + + await fakeServer.close() + const restartedServer = await startFakeMcpServer({ + tools, + onToolCall, + port: fakeServer.port, + sessionId: 'test-session-2' + }) + + try { + const second = await proxyClient.send('tools/call', { + name: 'upstream_echo', + arguments: {value: 'two'} + }) + ok(!second.result?.isError, second.result?.content?.[0]?.text ?? '') + + const call = await restartedServer.waitForToolCall() + ok(call.name === 'upstream_echo') + const callArgs = call.args && typeof call.args === 'object' ? call.args as {value?: unknown} : null + ok(callArgs?.value === 'two') + } finally { + await restartedServer.close() + } + }) + }) +}) diff --git a/build/mcp-servers/ij-proxy/integration-tests/tool-list.test.ts b/build/mcp-servers/ij-proxy/integration-tests/tool-list.test.ts index 2d1ff9f2ff5e..bd14f3574267 100644 --- a/build/mcp-servers/ij-proxy/integration-tests/tool-list.test.ts +++ b/build/mcp-servers/ij-proxy/integration-tests/tool-list.test.ts @@ -25,9 +25,18 @@ function getOtherModeOnlyNames(mode) { } describe('ij MCP proxy tool list', {timeout: SUITE_TIMEOUT_MS}, () => { - const upstreamToolsWithSearch = [ - ...defaultUpstreamTools, - buildUpstreamTool('search', {query: {type: 'string'}}, ['query']) + const defaultHasSearchSymbol = defaultUpstreamTools.some((tool) => tool.name === 'search_symbol') + const upstreamToolsWithLegacySearch = [ + buildUpstreamTool('search_in_files_by_text', {project_path: {type: 'string'}}, ['project_path']), + buildUpstreamTool('search_in_files_by_regex', {project_path: {type: 'string'}}, ['project_path']), + buildUpstreamTool('find_files_by_glob', {project_path: {type: 'string'}}, ['project_path']), + buildUpstreamTool('search', {query: {type: 'string'}, project_path: {type: 'string'}}, ['query', 'project_path']) + ] + const upstreamToolsWithSearchText = [ + buildUpstreamTool('search_text', {query: {type: 'string'}, project_path: {type: 'string'}}, ['query', 'project_path']) + ] + const upstreamToolsWithReadFile = [ + buildUpstreamTool('read_file', {path: {type: 'string'}}, ['path']) ] it('exposes proxy tools and hides replaced/blocked upstream tools', async () => { @@ -37,7 +46,11 @@ describe('ij MCP proxy tool list', {timeout: SUITE_TIMEOUT_MS}, () => { debug('test: tools/list response received') const names = listResponse.result.tools.map((tool) => tool.name) - assertContainsAll(names, getProxyToolNames(TOOL_MODES.CODEX)) + const expected = new Set(getProxyToolNames(TOOL_MODES.CODEX)) + if (!defaultHasSearchSymbol) { + expected.delete('search_symbol') + } + assertContainsAll(names, expected) assertExcludesAll(names, getOtherModeOnlyNames(TOOL_MODES.CODEX)) assertExcludesAll(names, BLOCKED_TOOL_NAMES) assertExcludesAll(names, getReplacedToolNames()) @@ -45,36 +58,45 @@ describe('ij MCP proxy tool list', {timeout: SUITE_TIMEOUT_MS}, () => { }) }) - it('exposes unified search tool and hides legacy search tools', async () => { - await withProxy({tools: upstreamToolsWithSearch}, async ({proxyClient}) => { + it('hides upstream search tool and keeps proxy search tools', async () => { + await withProxy({tools: upstreamToolsWithLegacySearch}, async ({proxyClient}) => { const listResponse = await proxyClient.send('tools/list') const names = listResponse.result.tools.map((tool) => tool.name) - ok(names.includes('search')) - ok(!names.includes('grep')) - ok(!names.includes('find')) + ok(!names.includes('search')) + ok(names.includes('search_text')) + ok(names.includes('search_regex')) + ok(names.includes('search_file')) }) }) - it('removes symbol target when upstream search is unavailable', async () => { - await withProxy({}, async ({proxyClient}) => { + it('does not expose search_symbol when upstream search_symbol is unavailable', async () => { + await withProxy({tools: upstreamToolsWithLegacySearch}, async ({proxyClient}) => { const listResponse = await proxyClient.send('tools/list') - const searchTool = listResponse.result.tools.find((tool) => tool.name === 'search') - ok(searchTool) - const targetSchema = searchTool.inputSchema?.properties?.target - const enumValues = Array.isArray(targetSchema?.enum) ? targetSchema.enum : [] - ok(!enumValues.includes('symbol')) + const names = listResponse.result.tools.map((tool) => tool.name) + ok(!names.includes('search_symbol')) }) }) - it('passes through upstream search tool schema when upstream search is available', async () => { - await withProxy({tools: upstreamToolsWithSearch}, async ({proxyClient}) => { + it('passes through upstream search schema when search_text is available', async () => { + await withProxy({tools: upstreamToolsWithSearchText}, async ({proxyClient}) => { const listResponse = await proxyClient.send('tools/list') - const searchTool = listResponse.result.tools.find((tool) => tool.name === 'search') + const searchTool = listResponse.result.tools.find((tool) => tool.name === 'search_text') ok(searchTool) const properties = searchTool.inputSchema?.properties ?? {} ok('query' in properties) - ok(!('target' in properties)) + ok(!('q' in properties)) + }) + }) + + it('passes through upstream read_file schema when read_file is available', async () => { + await withProxy({tools: upstreamToolsWithReadFile}, async ({proxyClient}) => { + const listResponse = await proxyClient.send('tools/list') + const readTool = listResponse.result.tools.find((tool) => tool.name === 'read_file') + ok(readTool) + const properties = readTool.inputSchema?.properties ?? {} + ok('path' in properties) + ok(!('file_path' in properties)) }) }) @@ -93,7 +115,11 @@ describe('ij MCP proxy tool list', {timeout: SUITE_TIMEOUT_MS}, () => { const listResponse = await proxyClient.send('tools/list') const names = listResponse.result.tools.map((tool) => tool.name) - assertContainsAll(names, getProxyToolNames(TOOL_MODES.CC)) + const expected = new Set(getProxyToolNames(TOOL_MODES.CC)) + if (!defaultHasSearchSymbol) { + expected.delete('search_symbol') + } + assertContainsAll(names, expected) assertExcludesAll(names, getOtherModeOnlyNames(TOOL_MODES.CC)) assertExcludesAll(names, BLOCKED_TOOL_NAMES) assertExcludesAll(names, getReplacedToolNames()) diff --git a/build/mcp-servers/ij-proxy/integration-tests/workarounds.test.ts b/build/mcp-servers/ij-proxy/integration-tests/workarounds.test.ts new file mode 100644 index 000000000000..7d9bb7004bce --- /dev/null +++ b/build/mcp-servers/ij-proxy/integration-tests/workarounds.test.ts @@ -0,0 +1,31 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {strictEqual} from 'node:assert/strict' +import {afterEach, describe, it} from 'bun:test' +import {setIdeVersion, shouldApplyWorkaround, WorkaroundKey} from '../workarounds' + +describe('ij MCP proxy workarounds', () => { + afterEach(() => { + setIdeVersion(null) + }) + + it('disables SearchInFilesByRegexDirectoryScopeIgnored at fixed build', () => { + setIdeVersion('261.20246') + strictEqual(shouldApplyWorkaround(WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored), true) + + setIdeVersion('261.20247') + strictEqual(shouldApplyWorkaround(WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored), false) + + setIdeVersion('261.20247.10') + strictEqual(shouldApplyWorkaround(WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored), false) + }) + + it('disables fixed-in build workarounds for train snapshots (e.g. 261.SNAPSHOT)', () => { + setIdeVersion('261.SNAPSHOT') + strictEqual(shouldApplyWorkaround(WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored), false) + + setIdeVersion('260.SNAPSHOT') + strictEqual(shouldApplyWorkaround(WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored), true) + }) +}) + diff --git a/build/mcp-servers/ij-proxy/package.json b/build/mcp-servers/ij-proxy/package.json index 66c13323e384..a4dfffee43d0 100644 --- a/build/mcp-servers/ij-proxy/package.json +++ b/build/mcp-servers/ij-proxy/package.json @@ -9,18 +9,16 @@ "build": "bun build.ts", "start": "bun dist/ij-mcp-proxy.mjs", "test": "bun test community/build/mcp-servers/ij-proxy/integration-tests/*.test.ts community/build/mcp-servers/ij-proxy/proxy-tools/handlers/*.test.ts", - "test:jb-mcp": "JETBRAINS_MCP_PROXY_WORKAROUND_DEBUG=1 JETBRAINS_MCP_STREAM_URL=http://127.0.0.1:64344/stream JETBRAINS_MCP_PROXY_DISABLE_WORKAROUND_KEYS=search_in_files_by_regex_directory_scope_ignored bun test ./integration-tests/jb-mcp-search-scope.test.ts" + "test:jb-mcp": "JETBRAINS_MCP_PROXY_WORKAROUND_DEBUG=1 JETBRAINS_MCP_STREAM_URL=http://127.0.0.1:64344/stream JETBRAINS_MCP_PROXY_DISABLE_WORKAROUND_KEYS=search_in_files_by_regex_directory_scope_ignored bun test ./integration-tests/jb-mcp-*.test.ts" }, "dependencies": { - "@eslint-community/regexpp": "^4.12.2", "@modelcontextprotocol/sdk": "^1.25.3", "is-port-reachable": "^4.0.0", "p-retry": "^7.1.1", - "p-timeout": "^7.0.1", "picomatch": "^4.0.3", "zod": "^4.3.6" }, "devDependencies": { - "@types/node": "^25.0.10" + "@types/node": "^25.1.0" } } diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/find.test.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/find.test.ts deleted file mode 100644 index 037db85ed995..000000000000 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/find.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. - -import path from 'node:path' -import {deepStrictEqual, rejects as assertRejects, strictEqual} from 'node:assert/strict' -import {describe, it} from 'bun:test' -import {handleFindTool} from './find' -import {createMockToolCaller} from './test-helpers' - -describe('find handler (unit)', () => { - const projectPath = '/project/root' - - it('uses glob search when pattern contains glob chars', async () => { - const {callUpstreamTool, calls} = createMockToolCaller({ - find_files_by_glob: () => ({structuredContent: {files: ['src/a.kt']}}) - }) - - const result = await handleFindTool({ - pattern: '*.kt', - path: 'src' - }, projectPath, callUpstreamTool) - - strictEqual(calls[0].name, 'find_files_by_glob') - deepStrictEqual(calls[0].args, { - globPattern: '*.kt', - fileCountLimit: 1000, - subDirectoryRelativePath: 'src' - }) - strictEqual(result, path.resolve(projectPath, 'src/a.kt')) - }) - - it('uses name search for plain patterns', async () => { - const {callUpstreamTool, calls} = createMockToolCaller({ - find_files_by_name_keyword: () => ({structuredContent: {files: ['src/Main.kt']}}) - }) - - const result = await handleFindTool({ - pattern: 'Main', - limit: 10 - }, projectPath, callUpstreamTool) - - strictEqual(calls[0].name, 'find_files_by_name_keyword') - deepStrictEqual(calls[0].args, {nameKeyword: 'Main', fileCountLimit: 10}) - strictEqual(result, path.resolve(projectPath, 'src/Main.kt')) - }) - - it('filters name results by base path', async () => { - const {callUpstreamTool} = createMockToolCaller({ - find_files_by_name_keyword: () => ({ - structuredContent: {files: ['src/a.txt', 'other/b.txt']} - }) - }) - - const result = await handleFindTool({ - pattern: 'a', - path: 'src' - }, projectPath, callUpstreamTool) - - strictEqual(result, path.resolve(projectPath, 'src/a.txt')) - }) - - it('retries name search when base path filters out initial results', async () => { - const firstBatch = Array.from({length: 1000}, (_, index) => `other/file-${index}.txt`) - const secondBatch = [ - ...firstBatch, - ...Array.from({length: 999}, (_, index) => `other/more-${index}.txt`), - 'src/file-target.txt' - ] - let callIndex = 0 - - const {callUpstreamTool, calls} = createMockToolCaller({ - find_files_by_name_keyword: () => ({ - structuredContent: {files: callIndex++ === 0 ? firstBatch : secondBatch} - }) - }) - - const result = await handleFindTool({ - pattern: 'file', - path: 'src', - limit: 1 - }, projectPath, callUpstreamTool) - - strictEqual(calls.length, 2) - strictEqual(calls[0].args.fileCountLimit, 1000) - strictEqual(calls[1].args.fileCountLimit, 2000) - strictEqual(result, path.resolve(projectPath, 'src/file-target.txt')) - }) - - it('honors explicit glob mode', async () => { - const {callUpstreamTool, calls} = createMockToolCaller({ - find_files_by_glob: () => ({structuredContent: {files: []}}) - }) - - const result = await handleFindTool({ - pattern: 'src', - mode: 'glob' - }, projectPath, callUpstreamTool) - - strictEqual(calls[0].name, 'find_files_by_glob') - strictEqual(result, 'No matches found.') - }) - - it('rejects unknown mode', async () => { - const {callUpstreamTool} = createMockToolCaller() - await assertRejects(() => handleFindTool({ - pattern: 'alpha', - mode: 'oops' - }, projectPath, callUpstreamTool), /mode must be one of/) - }) -}) diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/find.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/find.ts deleted file mode 100644 index 82f10ee99e0c..000000000000 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/find.ts +++ /dev/null @@ -1,158 +0,0 @@ -// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. - -import path from 'node:path' -import {extractFileList, extractStructuredContent, requireString, resolveSearchPath, toPositiveInt} from '../shared' -import type {UpstreamToolCaller} from '../types' - -const DEFAULT_LIMIT = 1000 -const NAME_SEARCH_MAX_LIMIT = 10000 -const GLOB_CHARS_RE = /[*?\[\]{}]/ - -interface FindToolArgs { - pattern?: unknown - query?: unknown - name?: unknown - mode?: unknown - limit?: unknown - path?: unknown - add_excluded?: unknown -} - -export interface FindFilesResult { - files: string[] - probablyHasMoreMatchingFiles: boolean - timedOut: boolean -} - -function resolvePattern(args: FindToolArgs | undefined): string | null { - if (args && typeof args.pattern === 'string') return args.pattern - if (args && typeof args.query === 'string') return args.query - if (args && typeof args.name === 'string') return args.name - return null -} - -function normalizeMode(value: unknown): 'auto' | 'glob' | 'name' { - if (typeof value !== 'string') return 'auto' - const mode = value.trim().toLowerCase() - if (mode === '') return 'auto' - if (mode === 'auto' || mode === 'glob' || mode === 'name') return mode - throw new Error('mode must be one of: auto, glob, name') -} - -function shouldUseGlob(pattern: string, mode: 'auto' | 'glob' | 'name'): boolean { - if (mode === 'glob') return true - if (mode === 'name') return false - return GLOB_CHARS_RE.test(pattern) || pattern.includes('/') || pattern.includes('\\') -} - -function filterByBasePath(files: string[], projectPath: string, baseRelative: string): string[] { - if (!baseRelative) return files - const normalizedBase = path.normalize(baseRelative) - const prefix = normalizedBase.endsWith(path.sep) ? normalizedBase : `${normalizedBase}${path.sep}` - return files.filter((file) => { - const relative = path.isAbsolute(file) - ? path.relative(projectPath, file) - : file - return relative === normalizedBase || relative.startsWith(prefix) - }) -} - -function toAbsolutePaths(files: string[], projectPath: string): string[] { - return files.map((file) => path.resolve(projectPath, file)) -} - -function extractFilesResult(result: unknown): FindFilesResult { - const files = extractFileList(result) - const structured = extractStructuredContent(result) - const structuredRecord = structured && typeof structured === 'object' - ? structured as Record - : null - return { - files, - probablyHasMoreMatchingFiles: structuredRecord?.probablyHasMoreMatchingFiles === true, - timedOut: structuredRecord?.timedOut === true - } -} - -async function findByNameKeyword( - pattern: string, - projectPath: string, - baseRelative: string, - limit: number, - callUpstreamTool: UpstreamToolCaller -): Promise { - const shouldFilter = Boolean(baseRelative) - let requestLimit = shouldFilter ? Math.max(limit, DEFAULT_LIMIT) : limit - const maxLimit = shouldFilter ? Math.max(limit, NAME_SEARCH_MAX_LIMIT) : limit - let timedOut = false - let probablyHasMoreMatchingFiles = false - - while (true) { - const result = await callUpstreamTool('find_files_by_name_keyword', { - nameKeyword: pattern, - fileCountLimit: requestLimit - }) - const extracted = extractFilesResult(result) - const files = extracted.files - timedOut = timedOut || extracted.timedOut - const hasMoreHint = extracted.probablyHasMoreMatchingFiles || files.length >= requestLimit - probablyHasMoreMatchingFiles = probablyHasMoreMatchingFiles || hasMoreHint - const filtered = shouldFilter ? filterByBasePath(files, projectPath, baseRelative) : files - const reachedLimit = filtered.length >= limit - - if (!shouldFilter || reachedLimit || files.length < requestLimit || requestLimit >= maxLimit) { - return { - files: filtered.slice(0, limit), - timedOut, - probablyHasMoreMatchingFiles: timedOut || probablyHasMoreMatchingFiles || reachedLimit - } - } - - requestLimit = Math.min(requestLimit * 2, maxLimit) - } -} - -export async function findFiles( - args: FindToolArgs, - projectPath: string, - callUpstreamTool: UpstreamToolCaller -): Promise { - const rawPattern = resolvePattern(args) - const pattern = requireString(rawPattern, 'pattern').trim() - const mode = normalizeMode(args?.mode) - const limit = toPositiveInt(args?.limit, DEFAULT_LIMIT, 'limit') - const basePath = args?.path - const {relative} = resolveSearchPath(projectPath, basePath) - - if (shouldUseGlob(pattern, mode)) { - const toolArgs = {globPattern: pattern, fileCountLimit: limit} - if (relative) { - toolArgs.subDirectoryRelativePath = relative - } - if (args?.add_excluded !== undefined) { - toolArgs.addExcluded = Boolean(args.add_excluded) - } - const result = await callUpstreamTool('find_files_by_glob', toolArgs) - const extracted = extractFilesResult(result) - const limited = extracted.files.slice(0, limit) - const reachedLimit = limited.length >= limit - return { - files: limited, - timedOut: extracted.timedOut, - probablyHasMoreMatchingFiles: extracted.timedOut || extracted.probablyHasMoreMatchingFiles || reachedLimit - } - } - - return await findByNameKeyword(pattern, projectPath, relative, limit, callUpstreamTool) -} - -export async function handleFindTool( - args: FindToolArgs, - projectPath: string, - callUpstreamTool: UpstreamToolCaller -): Promise { - const result = await findFiles(args, projectPath, callUpstreamTool) - const matches = result.files - if (matches.length === 0) return 'No matches found.' - return toAbsolutePaths(matches, projectPath).join('\n') -} diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.edge.test.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.edge.test.ts index ca22885860f1..f6855635a629 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.edge.test.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.edge.test.ts @@ -17,7 +17,7 @@ describe('read handler (edge cases)', () => { file_path: 'sample.txt', offset: 0, limit: 1 - }, projectPath, callUpstreamTool), + }, projectPath, callUpstreamTool, {hasReadFile: false}), /offset must be a positive integer/ ) }) @@ -30,7 +30,7 @@ describe('read handler (edge cases)', () => { file_path: 'sample.txt', offset: 1, limit: 0 - }, projectPath, callUpstreamTool), + }, projectPath, callUpstreamTool, {hasReadFile: false}), /limit must be a positive integer/ ) }) @@ -45,7 +45,7 @@ describe('read handler (edge cases)', () => { file_path: 'sample.txt', offset: 1, limit: 1 - }, projectPath, callUpstreamTool, {format: 'raw'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'raw'}) strictEqual(result, longLine) }) @@ -60,7 +60,7 @@ describe('read handler (edge cases)', () => { file_path: 'sample.txt', offset: 1, limit: 1 - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result.startsWith('L1: '), true) strictEqual(result.length, 4 + 500) @@ -76,7 +76,7 @@ describe('read handler (edge cases)', () => { file_path: 'sample.txt', offset: 1, limit: 3 - }, projectPath, callUpstreamTool, {format: 'numbered'}), + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}), /file content truncated while reading/ ) }) @@ -95,7 +95,7 @@ describe('read handler (edge cases)', () => { file_path: 'sample.txt', offset: 1, limit: 3 - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L1: alpha\nL2: beta\nL3: gamma') deepStrictEqual( @@ -130,7 +130,7 @@ describe('read handler (edge cases)', () => { include_siblings: false, include_header: false } - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result.includes('sibling2'), false) strictEqual(result.includes('sibling1'), true) @@ -162,7 +162,7 @@ describe('read handler (edge cases)', () => { include_siblings: true, include_header: false } - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result.includes('sibling2'), true) }) @@ -189,7 +189,7 @@ describe('read handler (edge cases)', () => { include_siblings: false, include_header: true } - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L2: parent\nL3: child') }) @@ -216,7 +216,7 @@ describe('read handler (edge cases)', () => { include_siblings: false, include_header: false } - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L2: parent\nL3: child') }) @@ -233,7 +233,7 @@ describe('read handler (edge cases)', () => { indentation: { max_levels: -1 } - }, projectPath, callUpstreamTool), + }, projectPath, callUpstreamTool, {hasReadFile: false}), /max_levels must be a non-negative integer/ ) @@ -246,7 +246,7 @@ describe('read handler (edge cases)', () => { indentation: { max_lines: 0 } - }, projectPath, callUpstreamTool), + }, projectPath, callUpstreamTool, {hasReadFile: false}), /max_lines must be a positive integer/ ) }) @@ -266,7 +266,7 @@ describe('read handler (edge cases)', () => { anchor_line: 5, max_levels: 1 } - }, projectPath, callUpstreamTool), + }, projectPath, callUpstreamTool, {hasReadFile: false}), /anchor_line exceeds file length/ ) }) diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.test.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.test.ts index e04e830416ce..2f8534672048 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.test.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.test.ts @@ -3,7 +3,7 @@ import {rejects, strictEqual} from 'node:assert/strict' import {realpathSync} from 'node:fs' import {describe, it} from 'bun:test' -import {SUITE_TIMEOUT_MS, TOOL_CALL_TIMEOUT_MS, withProxy, withTimeout} from '../../test-utils' +import {buildUpstreamTool, SUITE_TIMEOUT_MS, TOOL_CALL_TIMEOUT_MS, withProxy, withTimeout} from '../../test-utils' import {handleReadTool} from './read' import {createMockToolCaller, createSeededRng, randInt, randString} from './test-helpers' import {TRUNCATION_MARKER} from '../shared' @@ -41,6 +41,40 @@ describe('ij MCP proxy read_file', {timeout: SUITE_TIMEOUT_MS}, () => { }) }) + it('prefers upstream read_file when available', async () => { + const tools = [buildUpstreamTool('read_file', { + file_path: {type: 'string'}, + offset: {type: 'number'}, + limit: {type: 'number'} + }, ['file_path'])] + + await withProxy({ + tools, + onToolCall({name}) { + if (name === 'read_file') { + return {text: 'L2: beta\nL3: gamma'} + } + return {text: '{}'} + } + }, async ({fakeServer, proxyClient}) => { + await proxyClient.send('tools/list') + const callPromise = fakeServer.waitForToolCall() + const response = await proxyClient.send('tools/call', { + name: 'read_file', + arguments: { + file_path: 'sample.txt', + offset: 2, + limit: 2 + } + }) + const call = await withTimeout(callPromise, TOOL_CALL_TIMEOUT_MS, 'tools/call') + + strictEqual(call.name, 'read_file') + const content = response.result.content[0].text + strictEqual(content, 'L2: beta\nL3: gamma') + }) + }) + it('returns truncation error when upstream appends inline marker', async () => { const truncated = ['alpha', `beta${TRUNCATION_MARKER}`].join('\n') await withProxy({ @@ -103,7 +137,7 @@ describe('read handler (unit)', () => { file_path: 'sample.txt', offset: 3, limit: 1 - }, projectPath, callUpstreamTool, {format: 'numbered'}), + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}), /file content truncated while reading/ ) @@ -122,7 +156,7 @@ describe('read handler (unit)', () => { file_path: 'sample.txt', offset: 2, limit: 2 - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L2: beta\nL3: gamma') strictEqual(calls[0].args.maxLinesCount, 3) @@ -139,7 +173,7 @@ describe('read handler (unit)', () => { file_path: 'sample.txt', offset: 5, limit: 1 - }, projectPath, callUpstreamTool), + }, projectPath, callUpstreamTool, {hasReadFile: false}), /offset exceeds file length/ ) }) @@ -160,7 +194,7 @@ describe('read handler (unit)', () => { file_path: 'sample.txt', offset: 3, limit: 2 - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L3: gamma\nL4: delta') strictEqual(calls.length, 2) @@ -198,7 +232,7 @@ describe('read handler (unit)', () => { file_path: 'sample.txt', offset: 3, limit: 1 - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L3: gamma') strictEqual(calls.length, 3) @@ -226,7 +260,7 @@ describe('read handler (unit)', () => { anchor_line: 3, max_levels: 0 } - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L3: gamma') strictEqual(calls.length, 2) @@ -256,7 +290,7 @@ describe('read handler (unit)', () => { anchor_line: 3, max_levels: 0 } - }, projectPath, callUpstreamTool, {format: 'numbered'}), + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}), /file content truncated while reading/ ) @@ -280,7 +314,7 @@ describe('read handler (unit)', () => { anchor_line: 2, max_levels: 1 } - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, 'L2: child') }) @@ -309,7 +343,7 @@ describe('read handler (unit)', () => { anchor_line: 5, max_levels: 0 } - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) strictEqual(result, [ 'L1: /**', @@ -338,7 +372,7 @@ describe('read handler (unit)', () => { file_path: 'sample.txt', offset, limit - }, projectPath, callUpstreamTool, {format: 'numbered'}) + }, projectPath, callUpstreamTool, {hasReadFile: false}, {format: 'numbered'}) const end = Math.min(offset - 1 + limit, lineCount) const expected = [] diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.ts index 3efda29f4d86..d29581d10766 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/read.ts @@ -1,9 +1,18 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -import {readFileText, requireString, resolvePathInProject, splitLines, toNonNegativeInt, toPositiveInt, TRUNCATION_MARKER} from '../shared' +import { + extractTextFromResult, + readFileText, + requireString, + resolvePathInProject, + splitLines, + toNonNegativeInt, + toPositiveInt, + TRUNCATION_MARKER +} from '../shared' import {readLinesViaSearch} from '../search-fallback' import {findTruncationMarkerLine, findTruncationMarkerSuffix} from '../truncation' -import type {UpstreamToolCaller} from '../types' +import type {ReadCapabilities, UpstreamToolCaller} from '../types' const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 500 @@ -56,6 +65,7 @@ export async function handleReadTool( args: ReadToolArgs, projectPath: string, callUpstreamTool: UpstreamToolCaller, + readCapabilities: ReadCapabilities, {format = 'numbered'}: {format?: ReadFormat} = {} ): Promise { const filePath = requireString(args.file_path, 'file_path') @@ -78,6 +88,40 @@ export async function handleReadTool( const {relative, absolute} = resolvePathInProject(projectPath, filePath, 'file_path') + if (format !== 'raw' && readCapabilities.hasReadFile) { + const upstreamArgs: Record = { + file_path: relative, + offset, + limit + } + if (mode === 'indentation') { + upstreamArgs.mode = 'indentation' + const indentationPayload: Record = { + include_siblings: includeSiblings, + include_header: includeHeader, + max_levels: maxLevels + } + if (anchorLine != null) indentationPayload.anchor_line = anchorLine + if (maxLines != null) indentationPayload.max_lines = maxLines + upstreamArgs.indentation = indentationPayload + } else if (args.mode) { + upstreamArgs.mode = 'slice' + } + + try { + const result = await callUpstreamTool('read_file', upstreamArgs) + const text = extractTextFromResult(result) + if (typeof text === 'string') { + return text + } + if (typeof result === 'string') { + return result + } + } catch { + // Fall back to legacy path below when upstream read_file is unavailable or fails. + } + } + if (mode === 'indentation') { try { return await readIndentationMode(relative, offset, limit, { diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-constants.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-constants.ts new file mode 100644 index 000000000000..0de373d8ff9a --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-constants.ts @@ -0,0 +1,5 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +export const DEFAULT_MAX_RESULTS = 1000 +export const MAX_RESULTS_UPPER_BOUND = 5000 +export const SEARCH_SCOPE_MULTIPLIER = 5 diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.ts new file mode 100644 index 000000000000..ef3884d69d17 --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.ts @@ -0,0 +1,99 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {extractFileList, extractItems, extractStructuredContent, requireString} from '../shared' +import type {SearchCapabilities, ToolArgs, UpstreamToolCaller} from '../types' +import {normalizeItems, normalizeItemsFromFiles, normalizeLimit, resolveMoreFlag, serializeSearchResult} from './search-shared' +import {buildPathScope, expandLimit, filterFilesByScope, normalizeGlobPattern, type PathScope, resolveSearchRoot} from './search-scope' + +export async function handleSearchFileTool( + args: ToolArgs, + projectPath: string, + callUpstreamTool: UpstreamToolCaller, + capabilities: SearchCapabilities +): Promise { + const query = requireString(args.q, 'q').trim() + const includeExcluded = resolveIncludeExcluded(args) + const limit = normalizeLimit(args.limit) + const {scope, normalizedPaths} = buildPathScope(projectPath, args.paths) + + if (capabilities.hasSearchFile) { + const result = await callUpstreamTool('search_file', { + q: query, + ...(normalizedPaths ? {paths: normalizedPaths} : {}), + ...(includeExcluded ? {includeExcluded: true} : {}), + limit + }) + const items = normalizeItems(extractItems(result), projectPath, limit, false) + const more = resolveMoreFlag(result, items.length, limit) + return serializeSearchResult({items, more}) + } + + if (!capabilities.supportsFile) { + throw new Error('file search is not supported by this IDE version') + } + + return await searchFilesLegacy(query, scope, includeExcluded, limit, projectPath, callUpstreamTool) +} + +async function searchFilesLegacy( + query: string, + scope: PathScope | null, + includeExcluded: boolean, + limit: number, + projectPath: string, + callUpstreamTool: UpstreamToolCaller +): Promise { + const normalizedPattern = normalizeGlobPattern(query, projectPath) + const requestLimit = expandLimit(limit, scope) + const basePath = resolveSearchRoot(projectPath, scope, normalizedPattern) + const result = await findFilesByGlob(normalizedPattern, requestLimit, basePath, callUpstreamTool, includeExcluded) + + const filtered = scope ? filterFilesByScope(result.files, projectPath, scope) : result.files + const items = normalizeItemsFromFiles(filtered, projectPath, limit) + const more = result.timedOut || result.probablyHasMoreMatchingFiles || filtered.length > limit + return serializeSearchResult({items, more}) +} + +async function findFilesByGlob( + pattern: string, + limit: number, + basePath: string | null, + callUpstreamTool: UpstreamToolCaller, + includeExcluded: boolean +): Promise<{files: string[]; probablyHasMoreMatchingFiles: boolean; timedOut: boolean}> { + const toolArgs: Record = {globPattern: pattern, fileCountLimit: limit} + if (basePath) { + toolArgs.subDirectoryRelativePath = basePath + } + if (includeExcluded) { + toolArgs.addExcluded = true + } + const result = await callUpstreamTool('find_files_by_glob', toolArgs) + return extractFilesResult(result) +} + +function resolveIncludeExcluded(args: ToolArgs): boolean { + const raw = args.includeExcluded + if (raw === undefined || raw === null) return false + if (typeof raw !== 'boolean') { + throw new Error('includeExcluded must be a boolean') + } + return raw +} + +function extractFilesResult(result: unknown): { + files: string[]; + probablyHasMoreMatchingFiles: boolean; + timedOut: boolean; +} { + const files = extractFileList(result) + const structured = extractStructuredContent(result) + const structuredRecord = structured && typeof structured === 'object' + ? structured as Record + : null + return { + files, + probablyHasMoreMatchingFiles: structuredRecord?.probablyHasMoreMatchingFiles === true, + timedOut: structuredRecord?.timedOut === true + } +} diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.unit.test.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.unit.test.ts new file mode 100644 index 000000000000..d5eaed51ef87 --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-file.unit.test.ts @@ -0,0 +1,101 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {deepStrictEqual, strictEqual} from 'node:assert/strict' +import {mkdirSync, mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {describe, it} from 'bun:test' +import type {SearchCapabilities} from '../types' +import {handleSearchFileTool} from './search-file' +import {assertSingleCall, createMockToolCaller} from './test-helpers' + +const baseCapabilities: SearchCapabilities = { + hasSearchText: false, + hasSearchRegex: false, + hasSearchFile: false, + hasSearchSymbol: false, + supportsText: true, + supportsRegex: true, + supportsFile: true, + supportsSymbol: false +} + +describe('search file handler (unit)', () => { + it('filters files by scope and expands limits', async () => { + const projectPath = mkdtempSync(join(tmpdir(), 'ijproxy-search-')) + mkdirSync(join(projectPath, 'src'), {recursive: true}) + const {callUpstreamTool, calls} = createMockToolCaller({ + find_files_by_glob: () => ({ + structuredContent: { + files: ['src/a.txt', 'other/b.txt'] + } + }) + }) + + try { + const result = await handleSearchFileTool({ + q: '*.txt', + paths: ['src/'], + limit: 5 + }, projectPath, callUpstreamTool, baseCapabilities) + + const payload = JSON.parse(result) + deepStrictEqual(payload.items, [{filePath: 'src/a.txt'}]) + + const call = assertSingleCall(calls) + strictEqual(call.name, 'find_files_by_glob') + strictEqual(call.args.globPattern, '**/*.txt') + strictEqual(call.args.subDirectoryRelativePath, 'src') + strictEqual(call.args.fileCountLimit, 25) + } finally { + rmSync(projectPath, {recursive: true, force: true}) + } + }) + + it('dedupes files and respects limit', async () => { + const projectPath = mkdtempSync(join(tmpdir(), 'ijproxy-search-')) + const {callUpstreamTool} = createMockToolCaller({ + find_files_by_glob: () => ({ + structuredContent: { + files: ['src/a.txt', 'src/a.txt', 'src/b.txt'] + } + }) + }) + + try { + const result = await handleSearchFileTool({ + q: '*.txt', + limit: 1 + }, projectPath, callUpstreamTool, baseCapabilities) + + const payload = JSON.parse(result) + deepStrictEqual(payload.items, [{filePath: 'src/a.txt'}]) + } finally { + rmSync(projectPath, {recursive: true, force: true}) + } + }) + + it('forwards includeExcluded to legacy glob search', async () => { + const projectPath = mkdtempSync(join(tmpdir(), 'ijproxy-search-')) + const {callUpstreamTool, calls} = createMockToolCaller({ + find_files_by_glob: () => ({ + structuredContent: { + files: [] + } + }) + }) + + try { + await handleSearchFileTool({ + q: '*.txt', + includeExcluded: true, + limit: 1 + }, projectPath, callUpstreamTool, baseCapabilities) + + const call = assertSingleCall(calls) + strictEqual(call.args.addExcluded, true) + } finally { + rmSync(projectPath, {recursive: true, force: true}) + } + }) +}) diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-scope.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-scope.ts new file mode 100644 index 000000000000..44356a8e380f --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-scope.ts @@ -0,0 +1,281 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import path from 'node:path' +import {statSync} from 'node:fs' +import picomatch from 'picomatch' +import {normalizeEntryPath} from '../shared' +import type {SearchEntry} from '../types' +import {MAX_RESULTS_UPPER_BOUND, SEARCH_SCOPE_MULTIPLIER} from './search-constants' + +export interface PathScope { + includeMatchers: Array<(candidate: string) => boolean> + excludeMatchers: Array<(candidate: string) => boolean> + commonDirectory: string | null +} + +export interface PathScopeResult { + scope: PathScope | null + normalizedPaths: string[] | null +} + +export function buildPathScope(projectPath: string, rawPaths: unknown): PathScopeResult { + if (rawPaths === undefined || rawPaths === null) { + return {scope: null, normalizedPaths: null} + } + if (!Array.isArray(rawPaths)) { + throw new Error('paths must be an array of strings') + } + + const normalizedEntries: Array<{pattern: string; isExclude: boolean}> = [] + for (const entry of rawPaths) { + if (entry === undefined || entry === null) continue + if (typeof entry !== 'string') { + throw new Error('paths must be an array of strings') + } + const normalized = normalizePattern(entry, projectPath) + if (normalized) normalizedEntries.push(normalized) + } + + if (normalizedEntries.length === 0) { + return {scope: null, normalizedPaths: null} + } + + const normalizedPaths = normalizedEntries.map((entry) => + entry.isExclude ? `!${entry.pattern}` : entry.pattern + ) + + const includePatterns = normalizedEntries.filter((entry) => !entry.isExclude).map((entry) => entry.pattern) + const excludePatterns = normalizedEntries.filter((entry) => entry.isExclude).map((entry) => entry.pattern) + const effectiveIncludes = includePatterns.length > 0 ? includePatterns : ['**/*'] + + const includeMatchers = effectiveIncludes.map(createMatcher) + const excludeMatchers = excludePatterns.map(createMatcher) + const commonDirectory = computeCommonDirectory(effectiveIncludes) + + return { + scope: { + includeMatchers, + excludeMatchers, + commonDirectory + }, + normalizedPaths + } +} + +export function normalizeGlobPattern(raw: string, projectPath: string, originalPattern: string = raw): string { + let value = raw.trim() + if (value === '') throw new Error('Glob pattern is empty') + value = value.replace(/\\/g, '/') + while (value.startsWith('./')) { + value = value.slice(2) + } + + if (value.endsWith('/')) { + value = value.replace(/\/+$/, '') + value = value === '' ? '**' : `${value}/**` + } + + if (!value.includes('/')) { + value = `**/${value}` + } + + const normalized = normalizePathPattern(value, projectPath, originalPattern) + if (normalized === '') { + throw new Error(`Invalid glob pattern: ${originalPattern}`) + } + return normalized +} + +export function resolveSearchRoot(projectPath: string, scope: PathScope | null, globPattern: string | null): string | null { + const candidates: string[] = [] + if (scope?.commonDirectory) { + candidates.push(scope.commonDirectory) + } + if (globPattern) { + const prefix = extractDirectoryPrefix(globPattern) + if (prefix) candidates.push(prefix) + } + for (const candidate of candidates) { + if (!candidate) continue + const absolute = path.resolve(projectPath, candidate) + if (isDirectory(absolute)) { + return candidate + } + } + return null +} + +export function filterEntriesByScope(entries: SearchEntry[], projectPath: string, scope: PathScope): SearchEntry[] { + return entries.filter((entry) => { + const relative = resolveRelativePath(projectPath, entry.filePath) + if (!relative) return false + return matchesScope(scope, relative) + }) +} + +export function filterEntriesByDirectory( + entries: SearchEntry[], + projectPath: string, + directoryToSearch: string +): SearchEntry[] { + const absoluteDir = path.resolve(projectPath, directoryToSearch) + return entries.filter((entry) => { + const absolutePath = resolveAbsolutePath(projectPath, entry.filePath) + return absolutePath ? isWithinDirectory(absolutePath, absoluteDir) : false + }) +} + +export function filterFilesByScope(files: string[], projectPath: string, scope: PathScope): string[] { + return files.filter((filePath) => { + const relative = resolveRelativePath(projectPath, filePath) + if (!relative) return false + return matchesScope(scope, relative) + }) +} + +export function expandLimit(limit: number, scope: PathScope | null): number { + if (!scope) return limit + return Math.min(limit * SEARCH_SCOPE_MULTIPLIER, MAX_RESULTS_UPPER_BOUND) +} + +function normalizePattern(raw: string, projectPath: string): {pattern: string; isExclude: boolean} | null { + let value = raw.trim() + if (value === '') return null + + let isExclude = false + if (value.startsWith('!')) { + isExclude = true + value = value.slice(1).trim() + if (value === '') { + throw new Error('Exclude pattern is empty') + } + } + + const normalized = normalizeGlobPattern(value, projectPath, raw) + return {pattern: normalized, isExclude} +} + +function normalizePathPattern(pattern: string, projectPath: string, originalPattern: string): string { + const globIndex = indexOfGlobChar(pattern) + const prefix = globIndex < 0 ? pattern : pattern.slice(0, globIndex) + const prefixTrimmed = prefix.replace(/\/+$/, '') + if (prefixTrimmed === '') { + if (isAbsolutePattern(pattern)) { + throw new Error(`Specified path '${originalPattern}' points outside the project directory`) + } + return pattern + } + + const absolutePrefix = path.isAbsolute(prefixTrimmed) + ? path.normalize(prefixTrimmed) + : path.resolve(projectPath, prefixTrimmed) + + if (!isWithinProject(projectPath, absolutePrefix)) { + throw new Error(`Specified path '${originalPattern}' points outside the project directory`) + } + + const relativePrefix = toPosixPath(path.relative(projectPath, absolutePrefix)) + const suffix = pattern.slice(prefix.length).replace(/^\/+/, '') + if (relativePrefix === '') { + return suffix + } + if (suffix === '') { + return relativePrefix + } + return `${relativePrefix}/${suffix}` +} + +function isAbsolutePattern(pattern: string): boolean { + if (pattern.startsWith('/')) return true + return /^[A-Za-z]:\//.test(pattern) +} + +function indexOfGlobChar(pattern: string): number { + for (let i = 0; i < pattern.length; i += 1) { + const value = pattern[i] + if (value === '*' || value === '?' || value === '[' || value === ']' || value === '{' || value === '}') { + return i + } + } + return -1 +} + +function computeCommonDirectory(patterns: string[]): string | null { + const prefixes = patterns.map(extractDirectoryPrefix).filter((value): value is string => Boolean(value)) + if (prefixes.length === 0) return null + const segments = prefixes.map((value) => value.split('/').filter(Boolean)) + let common = segments[0] + for (const parts of segments.slice(1)) { + const max = Math.min(common.length, parts.length) + let index = 0 + while (index < max && common[index] === parts[index]) { + index += 1 + } + if (index === 0) return null + common = common.slice(0, index) + } + if (common.length === 0) return null + return path.normalize(common.join('/')) +} + +function extractDirectoryPrefix(pattern: string): string | null { + const globIndex = indexOfGlobChar(pattern) + const prefix = globIndex < 0 ? pattern : pattern.slice(0, globIndex) + const trimmed = prefix.replace(/\/+$/, '') + if (trimmed === '') return null + if (globIndex < 0) { + const slashIndex = trimmed.lastIndexOf('/') + if (slashIndex < 0) return null + const dir = trimmed.slice(0, slashIndex) + return dir === '' ? null : dir + } + return trimmed +} + +function createMatcher(pattern: string): (candidate: string) => boolean { + const nocase = path.sep === '\\' + const matcher = picomatch(pattern, {dot: true, nocase}) + return (candidate: string) => matcher(candidate) +} + +function isDirectory(candidatePath: string): boolean { + try { + return statSync(candidatePath).isDirectory() + } catch { + return false + } +} + +function resolveRelativePath(projectPath: string, filePath: unknown): string | null { + const absolute = resolveAbsolutePath(projectPath, filePath) + if (!absolute) return null + const relative = path.relative(projectPath, absolute) + if (relative.startsWith('..') || path.isAbsolute(relative)) return null + return toPosixPath(relative) +} + +function resolveAbsolutePath(projectPath: string, filePath: unknown): string | null { + const resolved = normalizeEntryPath(projectPath, filePath) + if (typeof resolved !== 'string' || resolved === '') return null + return path.normalize(resolved) +} + +function matchesScope(scope: PathScope, relativePosix: string): boolean { + const included = scope.includeMatchers.some((matcher) => matcher(relativePosix)) + if (!included) return false + return scope.excludeMatchers.every((matcher) => !matcher(relativePosix)) +} + +function isWithinProject(projectPath: string, candidatePath: string): boolean { + const relative = path.relative(projectPath, candidatePath) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function isWithinDirectory(filePath: string, directoryPath: string): boolean { + const relative = path.relative(directoryPath, filePath) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function toPosixPath(value: string): string { + return value.replace(/\\/g, '/') +} diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-shared.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-shared.ts new file mode 100644 index 000000000000..741efa6b0612 --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-shared.ts @@ -0,0 +1,123 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import path from 'node:path' +import {extractStructuredContent, toPositiveInt} from '../shared' +import type {SearchEntry, SearchItem} from '../types' +import {DEFAULT_MAX_RESULTS, MAX_RESULTS_UPPER_BOUND} from './search-constants' + +export interface SearchResultPayload { + items: SearchItem[] + more?: boolean +} + +export function normalizeLimit(value: unknown, fallback: number = DEFAULT_MAX_RESULTS): number { + const parsed = toPositiveInt(value, fallback, 'limit') ?? fallback + return Math.min(parsed, MAX_RESULTS_UPPER_BOUND) +} + +export function serializeSearchResult(payload: SearchResultPayload): string { + const result: Record = {items: payload.items} + if (payload.more) { + result.more = true + } + return JSON.stringify(result) +} + +export function normalizeItems( + items: SearchItem[], + projectPath: string, + maxResults: number, + includeDetails: boolean +): SearchItem[] { + const seen = new Set() + const normalized: SearchItem[] = [] + for (const item of items) { + const rawPath = item.filePath + if (!rawPath) continue + const normalizedPath = normalizeProjectRelativePath(projectPath, rawPath) + if (!normalizedPath) continue + const normalizedItem: SearchItem = {filePath: normalizedPath} + if (includeDetails && typeof item.lineNumber === 'number') { + normalizedItem.lineNumber = item.lineNumber + if (typeof item.lineText === 'string') { + normalizedItem.lineText = item.lineText + } + } + const key = JSON.stringify(normalizedItem) + if (seen.has(key)) continue + seen.add(key) + normalized.push(normalizedItem) + if (normalized.length >= maxResults) break + } + return normalized +} + +export function normalizeItemsFromEntries( + entries: SearchEntry[], + projectPath: string, + maxResults: number, + includeDetails: boolean +): SearchItem[] { + const items: SearchItem[] = [] + const seen = new Set() + for (const entry of entries) { + if (!entry || typeof entry.filePath !== 'string' || entry.filePath === '') continue + const normalizedPath = normalizeProjectRelativePath(projectPath, entry.filePath) + if (!normalizedPath) continue + const item: SearchItem = {filePath: normalizedPath} + if (includeDetails && typeof entry.lineNumber === 'number') { + item.lineNumber = entry.lineNumber + if (typeof entry.lineText === 'string') { + item.lineText = entry.lineText + } + } + const key = JSON.stringify(item) + if (seen.has(key)) continue + seen.add(key) + items.push(item) + if (items.length >= maxResults) break + } + return items +} + +export function normalizeItemsFromFiles(files: string[], projectPath: string, maxResults: number): SearchItem[] { + const items: SearchItem[] = [] + const seen = new Set() + for (const file of files) { + if (file === '') continue + const normalizedPath = normalizeProjectRelativePath(projectPath, file) + if (!normalizedPath) continue + if (seen.has(normalizedPath)) continue + seen.add(normalizedPath) + items.push({filePath: normalizedPath}) + if (items.length >= maxResults) break + } + return items +} + +export function resolveMoreFlag(result: unknown, itemCount: number, maxResults: number): boolean { + const structured = extractStructuredContent(result) + const structuredRecord = structured && typeof structured === 'object' + ? structured as Record + : null + if (structuredRecord?.more === true) return true + if (structuredRecord?.more === false) return false + if (structuredRecord?.probablyHasMoreMatchingEntries === true || structuredRecord?.timedOut === true) return true + return itemCount >= maxResults +} + +function normalizeProjectRelativePath(projectPath: string, filePath: string): string { + if (!filePath) return '' + if (path.isAbsolute(filePath)) { + const relative = path.relative(projectPath, filePath) + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + return toPosixPath(relative) + } + return path.normalize(filePath) + } + return toPosixPath(path.normalize(filePath)) +} + +function toPosixPath(value: string): string { + return value.replace(/\\/g, '/') +} diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-symbol.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-symbol.ts new file mode 100644 index 000000000000..431bf2b35ca6 --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-symbol.ts @@ -0,0 +1,30 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {extractItems, requireString} from '../shared' +import type {SearchCapabilities, ToolArgs, UpstreamToolCaller} from '../types' +import {normalizeItems, normalizeLimit, resolveMoreFlag, serializeSearchResult} from './search-shared' +import {buildPathScope} from './search-scope' + +export async function handleSearchSymbolTool( + args: ToolArgs, + projectPath: string, + callUpstreamTool: UpstreamToolCaller, + capabilities: SearchCapabilities +): Promise { + const query = requireString(args.q, 'q').trim() + const limit = normalizeLimit(args.limit) + const {normalizedPaths} = buildPathScope(projectPath, args.paths) + + if (capabilities.hasSearchSymbol) { + const result = await callUpstreamTool('search_symbol', { + q: query, + ...(normalizedPaths ? {paths: normalizedPaths} : {}), + limit + }) + const items = normalizeItems(extractItems(result), projectPath, limit, true) + const more = resolveMoreFlag(result, items.length, limit) + return serializeSearchResult({items, more}) + } + + throw new Error('symbol search is not supported by this IDE version') +} diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.ts new file mode 100644 index 000000000000..3e09b69cf149 --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.ts @@ -0,0 +1,122 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {extractItems, requireString} from '../shared' +import {searchInFiles} from '../search-in-files' +import {shouldApplyWorkaround, WorkaroundKey} from '../../workarounds' +import type {SearchCapabilities, ToolArgs, UpstreamToolCaller} from '../types' +import {normalizeItems, normalizeItemsFromEntries, normalizeLimit, resolveMoreFlag, serializeSearchResult} from './search-shared' +import { + buildPathScope, + expandLimit, + filterEntriesByDirectory, + filterEntriesByScope, + type PathScope, + resolveSearchRoot +} from './search-scope' + +export async function handleSearchTextTool( + args: ToolArgs, + projectPath: string, + callUpstreamTool: UpstreamToolCaller, + capabilities: SearchCapabilities +): Promise { + const query = requireString(args.q, 'q').trim() + const limit = normalizeLimit(args.limit) + const {scope, normalizedPaths} = buildPathScope(projectPath, args.paths) + + if (capabilities.hasSearchText) { + const result = await callUpstreamTool('search_text', { + q: query, + ...(normalizedPaths ? {paths: normalizedPaths} : {}), + limit + }) + const items = normalizeItems(extractItems(result), projectPath, limit, true) + const more = resolveMoreFlag(result, items.length, limit) + return serializeSearchResult({items, more}) + } + + if (!capabilities.supportsText) { + throw new Error('text search is not supported by this IDE version') + } + + return await searchTextLegacy(query, scope, limit, projectPath, callUpstreamTool) +} + +export async function handleSearchRegexTool( + args: ToolArgs, + projectPath: string, + callUpstreamTool: UpstreamToolCaller, + capabilities: SearchCapabilities +): Promise { + const query = requireString(args.q, 'q').trim() + const limit = normalizeLimit(args.limit) + const {scope, normalizedPaths} = buildPathScope(projectPath, args.paths) + + if (capabilities.hasSearchRegex) { + const result = await callUpstreamTool('search_regex', { + q: query, + ...(normalizedPaths ? {paths: normalizedPaths} : {}), + limit + }) + const items = normalizeItems(extractItems(result), projectPath, limit, true) + const more = resolveMoreFlag(result, items.length, limit) + return serializeSearchResult({items, more}) + } + + if (!capabilities.supportsRegex) { + throw new Error('regex search is not supported by this IDE version') + } + + return await searchRegexLegacy(query, scope, limit, projectPath, callUpstreamTool) +} + +async function searchTextLegacy( + query: string, + scope: PathScope | null, + limit: number, + projectPath: string, + callUpstreamTool: UpstreamToolCaller +): Promise { + const requestLimit = expandLimit(limit, scope) + const directoryToSearch = resolveSearchRoot(projectPath, scope, null) + const {entries, probablyHasMoreMatchingEntries, timedOut} = await searchInFiles({ + searchText: query, + directoryToSearch: directoryToSearch ?? undefined, + caseSensitive: true, + maxUsageCount: requestLimit + }, callUpstreamTool) + + const filtered = scope ? filterEntriesByScope(entries, projectPath, scope) : entries + const items = normalizeItemsFromEntries(filtered, projectPath, limit, true) + const more = timedOut || probablyHasMoreMatchingEntries || filtered.length > limit + return serializeSearchResult({items, more}) +} + +async function searchRegexLegacy( + query: string, + scope: PathScope | null, + limit: number, + projectPath: string, + callUpstreamTool: UpstreamToolCaller +): Promise { + const requestLimit = expandLimit(limit, scope) + const directoryToSearch = resolveSearchRoot(projectPath, scope, null) + const {entries, probablyHasMoreMatchingEntries, timedOut} = await searchInFiles({ + regexPattern: query, + directoryToSearch: directoryToSearch ?? undefined, + caseSensitive: true, + maxUsageCount: requestLimit + }, callUpstreamTool) + + let filtered = entries + if (directoryToSearch && shouldApplyWorkaround(WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored)) { + filtered = filterEntriesByDirectory(filtered, projectPath, directoryToSearch) + } + if (scope) { + filtered = filterEntriesByScope(filtered, projectPath, scope) + } + + const items = normalizeItemsFromEntries(filtered, projectPath, limit, true) + const more = timedOut || probablyHasMoreMatchingEntries || filtered.length > limit + return serializeSearchResult({items, more}) +} diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.unit.test.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.unit.test.ts new file mode 100644 index 000000000000..920bf53c7443 --- /dev/null +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search-text.unit.test.ts @@ -0,0 +1,126 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. + +import {deepStrictEqual, strictEqual} from 'node:assert/strict' +import {mkdirSync, mkdtempSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {describe, it} from 'bun:test' +import type {SearchCapabilities} from '../types' +import {handleSearchRegexTool, handleSearchTextTool} from './search-text' +import {assertSingleCall, createMockToolCaller} from './test-helpers' + +const baseCapabilities: SearchCapabilities = { + hasSearchText: false, + hasSearchRegex: false, + hasSearchFile: false, + hasSearchSymbol: false, + supportsText: true, + supportsRegex: true, + supportsFile: true, + supportsSymbol: false +} + +describe('search text handler (unit)', () => { + it('filters entries by path scope and expands limits', async () => { + const projectPath = mkdtempSync(join(tmpdir(), 'ijproxy-search-')) + mkdirSync(join(projectPath, 'src'), {recursive: true}) + const {callUpstreamTool, calls} = createMockToolCaller({ + search_in_files_by_text: () => ({ + structuredContent: { + entries: [ + {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}, + {filePath: 'src/b.txt', lineNumber: 1, lineText: 'beta'}, + {filePath: 'other/c.txt', lineNumber: 2, lineText: 'gamma'} + ] + } + }) + }) + + try { + const result = await handleSearchTextTool({ + q: 'alpha', + paths: ['src/'], + limit: 5 + }, projectPath, callUpstreamTool, baseCapabilities) + + const payload = JSON.parse(result) + deepStrictEqual(payload.items, [ + {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}, + {filePath: 'src/b.txt', lineNumber: 1, lineText: 'beta'} + ]) + + const call = assertSingleCall(calls) + strictEqual(call.name, 'search_in_files_by_text') + strictEqual(call.args.searchText, 'alpha') + strictEqual(call.args.directoryToSearch, 'src') + strictEqual(call.args.caseSensitive, true) + strictEqual(call.args.maxUsageCount, 25) + } finally { + rmSync(projectPath, {recursive: true, force: true}) + } + }) + + it('applies exclude patterns', async () => { + const projectPath = mkdtempSync(join(tmpdir(), 'ijproxy-search-')) + const {callUpstreamTool} = createMockToolCaller({ + search_in_files_by_text: () => ({ + structuredContent: { + entries: [ + {filePath: 'src/a.txt', lineNumber: 1, lineText: 'alpha'}, + {filePath: 'test/b.txt', lineNumber: 2, lineText: 'beta'} + ] + } + }) + }) + + try { + const result = await handleSearchTextTool({ + q: 'alpha', + paths: ['**/*.txt', '!**/test/**'], + limit: 10 + }, projectPath, callUpstreamTool, baseCapabilities) + + const payload = JSON.parse(result) + deepStrictEqual(payload.items, [{filePath: 'src/a.txt', lineNumber: 1, lineText: 'alpha'}]) + } finally { + rmSync(projectPath, {recursive: true, force: true}) + } + }) +}) + +describe('search regex handler (unit)', () => { + it('filters regex entries to directory scope when workaround is active', async () => { + const projectPath = mkdtempSync(join(tmpdir(), 'ijproxy-search-')) + mkdirSync(join(projectPath, 'src'), {recursive: true}) + const {callUpstreamTool, calls} = createMockToolCaller({ + search_in_files_by_regex: () => ({ + structuredContent: { + entries: [ + {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}, + {filePath: 'other/b.txt', lineNumber: 1, lineText: 'beta'} + ] + } + }) + }) + + try { + const result = await handleSearchRegexTool({ + q: 'a.*', + paths: ['src/'], + limit: 5 + }, projectPath, callUpstreamTool, baseCapabilities) + + const payload = JSON.parse(result) + deepStrictEqual(payload.items, [{filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}]) + + const call = assertSingleCall(calls) + strictEqual(call.name, 'search_in_files_by_regex') + strictEqual(call.args.regexPattern, 'a.*') + strictEqual(call.args.directoryToSearch, 'src') + strictEqual(call.args.caseSensitive, true) + strictEqual(call.args.maxUsageCount, 25) + } finally { + rmSync(projectPath, {recursive: true, force: true}) + } + }) +}) diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.test.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.test.ts index 1ec3e86a739b..7fb684a102c9 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.test.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.test.ts @@ -1,15 +1,20 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. import {deepStrictEqual, strictEqual} from 'node:assert/strict' -import {realpathSync} from 'node:fs' +import {mkdirSync, realpathSync} from 'node:fs' +import {join} from 'node:path' import {describe, it} from 'bun:test' -import {SUITE_TIMEOUT_MS, withProxy} from '../../test-utils' +import {buildUpstreamTool, SUITE_TIMEOUT_MS, withProxy} from '../../test-utils' describe('ij MCP proxy search', {timeout: SUITE_TIMEOUT_MS}, () => { it('returns content output and forwards text options', async () => { const calls = [] + const tools = [ + buildUpstreamTool('search_in_files_by_text', {project_path: {type: 'string'}}, ['project_path']) + ] await withProxy({ proxyEnv: {JETBRAINS_MCP_TOOL_MODE: 'cc'}, + tools, onToolCall({name, args}) { calls.push({name, args}) if (name === 'search_in_files_by_text') { @@ -17,7 +22,8 @@ describe('ij MCP proxy search', {timeout: SUITE_TIMEOUT_MS}, () => { structuredContent: { entries: [ {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}, - {filePath: 'src/b.txt', lineNumber: 1, lineText: 'beta'} + {filePath: 'src/b.txt', lineNumber: 1, lineText: 'beta'}, + {filePath: 'other/c.txt', lineNumber: 2, lineText: 'gamma'} ] } } @@ -25,23 +31,20 @@ describe('ij MCP proxy search', {timeout: SUITE_TIMEOUT_MS}, () => { return {text: '{}'} } }, async ({proxyClient, testDir}) => { - await proxyClient.send('tools/list') + mkdirSync(join(testDir, 'src'), {recursive: true}) const response = await proxyClient.send('tools/call', { - name: 'search', + name: 'search_text', arguments: { - query: 'alpha', - target: 'text', - path: 'src', - file_mask: '*.txt', - output: 'entries', - max_results: 5 + q: 'alpha', + paths: ['src/'], + limit: 5 } }) const payload = JSON.parse(response.result.content[0].text) deepStrictEqual(payload.items, [ - ['src/a.txt', 3, 'alpha'], - ['src/b.txt', 1, 'beta'] + {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}, + {filePath: 'src/b.txt', lineNumber: 1, lineText: 'beta'} ]) strictEqual(calls.length, 1) @@ -49,17 +52,152 @@ describe('ij MCP proxy search', {timeout: SUITE_TIMEOUT_MS}, () => { strictEqual(call.name, 'search_in_files_by_text') strictEqual(call.args.searchText, 'alpha') strictEqual(call.args.directoryToSearch, 'src') - strictEqual(call.args.fileMask, '*.txt') + strictEqual(call.args.fileMask, undefined) strictEqual(call.args.caseSensitive, true) - strictEqual(call.args.maxUsageCount, 5) + strictEqual(call.args.maxUsageCount, 25) strictEqual(realpathSync(call.args.project_path), realpathSync(testDir)) }) }) + it('forces legacy search when new search is disabled', async () => { + const calls = [] + const tools = [ + buildUpstreamTool('search_text', {project_path: {type: 'string'}}, ['project_path']), + buildUpstreamTool('search_in_files_by_text', {project_path: {type: 'string'}}, ['project_path']) + ] + await withProxy({ + proxyEnv: { + JETBRAINS_MCP_TOOL_MODE: 'cc', + JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH: 'true' + }, + tools, + onToolCall({name, args}) { + calls.push({name, args}) + if (name === 'search_in_files_by_text') { + return { + structuredContent: { + entries: [ + {filePath: 'src/a.txt', lineNumber: 1, lineText: 'alpha'} + ] + } + } + } + return {text: '{}'} + } + }, async ({proxyClient, testDir}) => { + mkdirSync(join(testDir, 'src'), {recursive: true}) + await proxyClient.send('tools/list') + const response = await proxyClient.send('tools/call', { + name: 'search_text', + arguments: { + q: 'alpha', + paths: ['src/'], + limit: 5 + } + }) + + const payload = JSON.parse(response.result.content[0].text) + deepStrictEqual(payload.items, [ + {filePath: 'src/a.txt', lineNumber: 1, lineText: 'alpha'} + ]) + strictEqual(calls.length, 1) + strictEqual(calls[0].name, 'search_in_files_by_text') + }) + }) + + it('forces legacy regex search when new search is disabled', async () => { + const calls = [] + const tools = [ + buildUpstreamTool('search_regex', {project_path: {type: 'string'}}, ['project_path']), + buildUpstreamTool('search_in_files_by_regex', {project_path: {type: 'string'}}, ['project_path']) + ] + await withProxy({ + proxyEnv: { + JETBRAINS_MCP_TOOL_MODE: 'cc', + JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH: 'true' + }, + tools, + onToolCall({name, args}) { + calls.push({name, args}) + if (name === 'search_in_files_by_regex') { + return { + structuredContent: { + entries: [ + {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}, + {filePath: 'other/b.txt', lineNumber: 1, lineText: 'beta'} + ] + } + } + } + return {text: '{}'} + } + }, async ({proxyClient, testDir}) => { + mkdirSync(join(testDir, 'src'), {recursive: true}) + const response = await proxyClient.send('tools/call', { + name: 'search_regex', + arguments: { + q: 'a.*', + paths: ['src/'], + limit: 5 + } + }) + + const payload = JSON.parse(response.result.content[0].text) + deepStrictEqual(payload.items, [ + {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'} + ]) + strictEqual(calls.length, 1) + strictEqual(calls[0].name, 'search_in_files_by_regex') + }) + }) + + it('forces legacy file search when new search is disabled', async () => { + const calls = [] + const tools = [ + buildUpstreamTool('search_file', {project_path: {type: 'string'}}, ['project_path']), + buildUpstreamTool('find_files_by_glob', {project_path: {type: 'string'}}, ['project_path']) + ] + await withProxy({ + proxyEnv: { + JETBRAINS_MCP_TOOL_MODE: 'cc', + JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH: 'true' + }, + tools, + onToolCall({name, args}) { + calls.push({name, args}) + if (name === 'find_files_by_glob') { + return {structuredContent: {files: ['src/a.txt', 'other/b.txt']}} + } + return {text: '{}'} + } + }, async ({proxyClient, testDir}) => { + mkdirSync(join(testDir, 'src'), {recursive: true}) + const response = await proxyClient.send('tools/call', { + name: 'search_file', + arguments: { + q: '*.txt', + paths: ['src/'], + includeExcluded: true, + limit: 5 + } + }) + + const payload = JSON.parse(response.result.content[0].text) + deepStrictEqual(payload.items, [{filePath: 'src/a.txt'}]) + strictEqual(calls.length, 1) + strictEqual(calls[0].name, 'find_files_by_glob') + strictEqual(calls[0].args.addExcluded, true) + }) + }) + it('returns absolute matches and forwards subdirectory for glob', async () => { const calls = [] + const tools = [ + buildUpstreamTool('find_files_by_glob', {project_path: {type: 'string'}}, ['project_path']) + ] await withProxy({ proxyEnv: {JETBRAINS_MCP_TOOL_MODE: 'cc'}, + tools, onToolCall({name, args}) { calls.push({name, args}) if (name === 'find_files_by_glob') { @@ -68,19 +206,18 @@ describe('ij MCP proxy search', {timeout: SUITE_TIMEOUT_MS}, () => { return {text: '{}'} } }, async ({proxyClient, testDir}) => { + mkdirSync(join(testDir, 'src'), {recursive: true}) await proxyClient.send('tools/list') const response = await proxyClient.send('tools/call', { - name: 'search', + name: 'search_file', arguments: { - query: '**/*.txt', - target: 'file', - query_type: 'glob', - path: 'src' + q: '*.txt', + paths: ['src/'] } }) const payload = JSON.parse(response.result.content[0].text) - deepStrictEqual(payload.items, [['src/a.txt'], ['src/b.txt']]) + deepStrictEqual(payload.items, [{filePath: 'src/a.txt'}, {filePath: 'src/b.txt'}]) strictEqual(calls.length, 1) const call = calls[0] @@ -91,10 +228,61 @@ describe('ij MCP proxy search', {timeout: SUITE_TIMEOUT_MS}, () => { }) }) - it('returns a message when no file matches are found', async () => { + it('filters regex results to directory when workaround is active', async () => { const calls = [] + const tools = [ + buildUpstreamTool('search_in_files_by_regex', {project_path: {type: 'string'}}, ['project_path']) + ] await withProxy({ proxyEnv: {JETBRAINS_MCP_TOOL_MODE: 'cc'}, + tools, + onToolCall({name, args}) { + calls.push({name, args}) + if (name === 'search_in_files_by_regex') { + return { + structuredContent: { + entries: [ + {filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}, + {filePath: 'other/b.txt', lineNumber: 1, lineText: 'beta'} + ] + } + } + } + return {text: '{}'} + } + }, async ({proxyClient, testDir}) => { + mkdirSync(join(testDir, 'src'), {recursive: true}) + await proxyClient.send('tools/list') + const response = await proxyClient.send('tools/call', { + name: 'search_regex', + arguments: { + q: 'a.*', + paths: ['src/'], + limit: 5 + } + }) + + const payload = JSON.parse(response.result.content[0].text) + deepStrictEqual(payload.items, [{filePath: 'src/a.txt', lineNumber: 3, lineText: 'alpha'}]) + strictEqual(calls.length, 1) + + const call = calls[0] + strictEqual(call.name, 'search_in_files_by_regex') + strictEqual(call.args.regexPattern, 'a.*') + strictEqual(call.args.directoryToSearch, 'src') + strictEqual(call.args.caseSensitive, true) + strictEqual(realpathSync(call.args.project_path), realpathSync(testDir)) + }) + }) + + it('returns a message when no file matches are found', async () => { + const calls = [] + const tools = [ + buildUpstreamTool('find_files_by_glob', {project_path: {type: 'string'}}, ['project_path']) + ] + await withProxy({ + proxyEnv: {JETBRAINS_MCP_TOOL_MODE: 'cc'}, + tools, onToolCall({name, args}) { calls.push({name, args}) if (name === 'find_files_by_glob') { @@ -105,18 +293,16 @@ describe('ij MCP proxy search', {timeout: SUITE_TIMEOUT_MS}, () => { }, async ({proxyClient}) => { await proxyClient.send('tools/list') const response = await proxyClient.send('tools/call', { - name: 'search', + name: 'search_file', arguments: { - query: '*.md', - target: 'file', - query_type: 'glob' + q: '*.md' } }) const payload = JSON.parse(response.result.content[0].text) deepStrictEqual(payload.items, []) strictEqual(calls.length, 1) - strictEqual(calls[0].args.globPattern, '*.md') + strictEqual(calls[0].args.globPattern, '**/*.md') strictEqual(calls[0].args.subDirectoryRelativePath, undefined) }) }) diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.ts index 058354e69044..400b5b39e643 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/search.ts @@ -1,493 +1,5 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -import path from 'node:path' -import { - extractItems, - extractStructuredContent, - looksLikeFilePath, - normalizeEntryPath, - requireString, - resolveSearchPath, - toPositiveInt -} from '../shared' -import {searchInFiles} from '../search-in-files' -import {findFiles} from './find' -import type {SearchCapabilities, SearchEntry, UpstreamToolCaller} from '../types' - -const DEFAULT_MAX_RESULTS = 100 -const GLOB_CHARS_RE = /[*?\[\]{}]/ - -type OutputMode = 'entries' | 'files' -type QueryType = 'text' | 'regex' | 'glob' -type Target = 'symbol' | 'file' | 'text' -type SearchItem = [string] | [string, number] | [string, number, string] - -interface SearchToolArgs { - query?: unknown - pattern?: unknown - text?: unknown - name?: unknown - target?: unknown - kind?: unknown - query_type?: unknown - queryType?: unknown - type?: unknown - path?: unknown - directory?: unknown - directory_to_search?: unknown - directoryToSearch?: unknown - file_mask?: unknown - fileMask?: unknown - mask?: unknown - case_sensitive?: unknown - caseSensitive?: unknown - max_results?: unknown - maxResults?: unknown - limit?: unknown - output?: unknown - output_mode?: unknown -} - -export async function handleSearchTool( - args: SearchToolArgs, - projectPath: string, - callUpstreamTool: UpstreamToolCaller, - capabilities: SearchCapabilities -): Promise { - const query = requireString(resolveQuery(args), 'query').trim() - const rawTarget = normalizeString(args.target ?? args.kind) - const rawQueryType = normalizeString(args.query_type ?? args.queryType ?? args.type) - const rawOutput = normalizeString(args.output ?? args.output_mode) - const pathArg = resolvePathArg(args) - const fileMask = resolveFileMask(args) - const caseSensitive = resolveBoolean(args.case_sensitive ?? args.caseSensitive, true) - const maxResults = toPositiveInt( - args.max_results ?? args.maxResults ?? args.limit, - DEFAULT_MAX_RESULTS, - 'max_results' - ) - - const target = resolveTarget(query, rawTarget, rawQueryType, capabilities) - const queryType = resolveQueryType(query, target, rawQueryType, capabilities) - const output = resolveOutput(rawOutput, target) - - if (target === 'symbol') { - return await searchSymbols(query, { - pathArg, - fileMask, - maxResults, - output - }, projectPath, callUpstreamTool, capabilities) - } - - if (target === 'file') { - return await searchFiles(query, { - pathArg, - maxResults, - queryType - }, projectPath, callUpstreamTool, capabilities) - } - - return await searchText(query, { - pathArg, - fileMask, - caseSensitive, - maxResults, - output, - queryType - }, projectPath, callUpstreamTool, capabilities) -} - -function resolveQuery(args: SearchToolArgs): unknown { - if (args.query !== undefined) return args.query - if (args.pattern !== undefined) return args.pattern - if (args.text !== undefined) return args.text - if (args.name !== undefined) return args.name - return args.query -} - -function normalizeString(value: unknown): string | null { - if (typeof value !== 'string') return null - const trimmed = value.trim().toLowerCase() - return trimmed === '' ? null : trimmed -} - -function resolveBoolean(value: unknown, fallback: boolean): boolean { - if (value === undefined || value === null) return fallback - if (typeof value === 'boolean') return value - if (typeof value === 'number') return value !== 0 - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase() - if (normalized === 'false' || normalized === '0' || normalized === 'no') return false - if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true - } - return Boolean(value) -} - -function resolvePathArg(args: SearchToolArgs): unknown { - if (args.path !== undefined) return args.path - if (args.directory !== undefined) return args.directory - if (args.directory_to_search !== undefined) return args.directory_to_search - if (args.directoryToSearch !== undefined) return args.directoryToSearch - return undefined -} - -function resolveFileMask(args: SearchToolArgs): string | undefined { - if (typeof args.file_mask === 'string') return args.file_mask - if (typeof args.fileMask === 'string') return args.fileMask - if (typeof args.mask === 'string') return args.mask - return undefined -} - -function resolveTarget( - query: string, - rawTarget: string | null, - rawQueryType: string | null, - capabilities: SearchCapabilities -): Target { - const allowedTargets = new Set() - if (capabilities.supportsSymbol) allowedTargets.add('symbol') - if (capabilities.supportsFile) allowedTargets.add('file') - if (capabilities.supportsText) allowedTargets.add('text') - - if (rawTarget && rawTarget !== 'auto') { - if (!allowedTargets.has(rawTarget)) { - throw new Error(`target must be one of: auto, ${[...allowedTargets].join(', ')}`) - } - return rawTarget as Target - } - - if (rawQueryType === 'glob') return 'file' - if (rawQueryType === 'regex') return 'text' - if (looksLikeGlob(query) || looksLikePath(query)) return 'file' - if (containsWhitespace(query)) return 'text' - if (capabilities.supportsSymbol) return 'symbol' - if (capabilities.supportsFile) return 'file' - return 'text' -} - -function resolveQueryType( - query: string, - target: Target, - rawQueryType: string | null, - capabilities: SearchCapabilities -): QueryType { - let queryType: QueryType - if (rawQueryType === 'regex' || rawQueryType === 'glob' || rawQueryType === 'text') { - queryType = rawQueryType - } else { - queryType = target === 'file' && looksLikeGlob(query) ? 'glob' : 'text' - } - - if (queryType === 'glob' && target !== 'file') { - throw new Error('query_type=glob requires target=file') - } - if (queryType === 'regex' && target === 'file') { - throw new Error('query_type=regex requires target=text') - } - - if (queryType === 'regex' && !capabilities.supportsRegex) { - throw new Error('query_type=regex is not supported by this IDE version') - } - if (queryType === 'glob' && !capabilities.supportsFileGlob) { - throw new Error('query_type=glob is not supported by this IDE version') - } - if (target === 'file' && queryType === 'text' && !capabilities.supportsFileName) { - if (capabilities.supportsFileGlob) return 'glob' - throw new Error('file name search is not supported by this IDE version') - } - return queryType -} - -function resolveOutput(rawOutput: string | null, target: Target): OutputMode { - if (!rawOutput) { - return target === 'file' ? 'files' : 'entries' - } - if (rawOutput === 'files' || rawOutput === 'entries') return rawOutput - throw new Error('output must be one of: entries, files') -} - -async function searchSymbols( - query: string, - options: { - pathArg: unknown - fileMask?: string - maxResults: number - output: OutputMode - }, - projectPath: string, - callUpstreamTool: UpstreamToolCaller, - capabilities: SearchCapabilities -): Promise { - if (!capabilities.supportsSymbol) { - throw new Error('symbol search is not supported by this IDE version') - } - - const {relative} = resolveSearchPath(projectPath, options.pathArg) - const toolArgs: Record = { - query, - maxResults: options.maxResults, - output: options.output === 'files' ? 'files' : 'entries', - providers: ['classes', 'symbols'] - } - if (relative) { - toolArgs.directoryToSearch = relative - } - if (options.fileMask) { - toolArgs.fileMask = options.fileMask - } - - const result = await callUpstreamTool('search', toolArgs) - const items = normalizeItems( - extractItems(result), - projectPath, - options.maxResults, - options.output === 'entries' - ) - const more = resolveMoreFlag(result, items.length, options.maxResults) - return serializeSearchResult({items, more}) -} - -async function searchFiles( - query: string, - options: { - pathArg: unknown - maxResults: number - queryType: QueryType - }, - projectPath: string, - callUpstreamTool: UpstreamToolCaller, - capabilities: SearchCapabilities -): Promise { - if (!capabilities.supportsFile) { - throw new Error('file search is not supported by this IDE version') - } - - const mode = options.queryType === 'glob' ? 'glob' : 'name' - const result = await findFiles({ - pattern: query, - mode, - limit: options.maxResults, - path: options.pathArg - }, projectPath, callUpstreamTool) - const items = normalizeItemsFromFiles(result.files, projectPath, options.maxResults) - const more = result.timedOut || result.probablyHasMoreMatchingFiles || result.files.length >= options.maxResults - return serializeSearchResult({items, more}) -} - -async function searchText( - query: string, - options: { - pathArg: unknown - fileMask?: string - caseSensitive: boolean - maxResults: number - output: OutputMode - queryType: QueryType - }, - projectPath: string, - callUpstreamTool: UpstreamToolCaller, - capabilities: SearchCapabilities -): Promise { - if (!capabilities.supportsText) { - throw new Error('text search is not supported by this IDE version') - } - - const {relative} = resolveSearchPath(projectPath, options.pathArg) - let directoryToSearch = relative || undefined - let fileMask = options.fileMask - let treatAsFile = false - - if (relative && typeof options.pathArg === 'string' && looksLikeFilePath(options.pathArg, relative)) { - treatAsFile = true - directoryToSearch = path.dirname(relative) - fileMask = fileMask ?? path.basename(relative) - } - - const maxUsageCount = options.output === 'files' - ? Math.min(options.maxResults * 5, 1000) - : options.maxResults - - const {entries, probablyHasMoreMatchingEntries, timedOut} = await searchInFiles({ - directoryToSearch, - fileMask, - caseSensitive: options.caseSensitive, - maxUsageCount, - ...(options.queryType === 'regex' ? {regexPattern: query} : {searchText: query}) - }, callUpstreamTool) - - const filtered = normalizeEntries(filterEntriesByPath(entries, projectPath, relative, treatAsFile), projectPath) - const entryCount = filtered.length - const more = timedOut || probablyHasMoreMatchingEntries || entryCount >= options.maxResults - if (options.output === 'files') { - const items = normalizeItemsFromEntries(filtered, projectPath, options.maxResults, false) - return serializeSearchResult({items, more}) - } - const items = normalizeItemsFromEntries(filtered, projectPath, options.maxResults, true) - return serializeSearchResult({items, more}) -} - -interface SearchResultPayload { - items: SearchItem[] - more?: boolean -} - -function serializeSearchResult(payload: SearchResultPayload): string { - const result: Record = {items: payload.items} - if (payload.more) { - result.more = true - } - return JSON.stringify(result) -} - -function normalizeProjectRelativePath(projectPath: string, filePath: string): string { - if (!filePath) return '' - if (path.isAbsolute(filePath)) { - const relative = path.relative(projectPath, filePath) - if (!relative.startsWith('..') && !path.isAbsolute(relative)) { - return relative - } - return path.normalize(filePath) - } - return path.normalize(filePath) -} - -function normalizeEntries(entries: SearchEntry[], projectPath: string): SearchEntry[] { - return entries.map((entry) => { - const filePath = typeof entry.filePath === 'string' - ? normalizeProjectRelativePath(projectPath, entry.filePath) - : entry.filePath - if (filePath === entry.filePath) return entry - return {...entry, filePath} - }) -} - -function normalizeItems( - items: SearchItem[], - projectPath: string, - maxResults: number, - includeDetails: boolean -): SearchItem[] { - const seen = new Set() - const normalized: SearchItem[] = [] - for (const item of items) { - const rawPath = item[0] - if (rawPath === '') continue - const normalizedPath = normalizeProjectRelativePath(projectPath, rawPath) - if (!normalizedPath) continue - const line = includeDetails && typeof item[1] === 'number' ? item[1] : undefined - const text = includeDetails && typeof item[2] === 'string' ? item[2] : undefined - const normalizedItem: SearchItem = line === undefined - ? [normalizedPath] - : text === undefined - ? [normalizedPath, line] - : [normalizedPath, line, text] - const key = JSON.stringify(normalizedItem) - if (seen.has(key)) continue - seen.add(key) - normalized.push(normalizedItem) - if (normalized.length >= maxResults) break - } - return normalized -} - -function normalizeItemsFromEntries( - entries: SearchEntry[], - projectPath: string, - maxResults: number, - includeDetails: boolean -): SearchItem[] { - const items: SearchItem[] = [] - const seen = new Set() - for (const entry of entries) { - if (!entry || typeof entry.filePath !== 'string' || entry.filePath === '') continue - const normalizedPath = normalizeProjectRelativePath(projectPath, entry.filePath) - if (!normalizedPath) continue - const line = includeDetails && typeof entry.lineNumber === 'number' ? entry.lineNumber : undefined - const text = includeDetails && typeof entry.lineText === 'string' ? entry.lineText : undefined - const item: SearchItem = line === undefined - ? [normalizedPath] - : text === undefined - ? [normalizedPath, line] - : [normalizedPath, line, text] - const key = JSON.stringify(item) - if (seen.has(key)) continue - seen.add(key) - items.push(item) - if (items.length >= maxResults) break - } - return items -} - -function normalizeItemsFromFiles(files: string[], projectPath: string, maxResults: number): SearchItem[] { - const items: SearchItem[] = [] - const seen = new Set() - for (const file of files) { - if (file === '') continue - const normalizedPath = normalizeProjectRelativePath(projectPath, file) - if (!normalizedPath) continue - const item: SearchItem = [normalizedPath] - if (seen.has(normalizedPath)) continue - seen.add(normalizedPath) - items.push(item) - if (items.length >= maxResults) break - } - return items -} - -function resolveMoreFlag(result: unknown, itemCount: number, maxResults: number): boolean { - const structured = extractStructuredContent(result) - const structuredRecord = structured && typeof structured === 'object' - ? structured as Record - : null - if (structuredRecord?.more === true) return true - if (structuredRecord?.more === false) return false - if (structuredRecord?.probablyHasMoreMatchingEntries === true || structuredRecord?.timedOut === true) return true - return itemCount >= maxResults -} - -function filterEntriesByPath( - entries: SearchEntry[], - projectPath: string, - relativePath: string, - treatAsFile: boolean -): SearchEntry[] { - if (!relativePath) return entries - const filter = createEntryPathFilter(projectPath, relativePath, treatAsFile) - return filter ? entries.filter(filter) : entries -} - -function createEntryPathFilter( - projectPath: string, - relativePath: string, - treatAsFile: boolean -): ((entry: SearchEntry) => boolean) | null { - if (!relativePath) return null - const targetPath = path.normalize(path.resolve(projectPath, relativePath)) - if (treatAsFile) { - return (entry) => normalizeEntryPath(projectPath, entry.filePath) === targetPath - } - return (entry) => { - const entryPath = normalizeEntryPath(projectPath, entry.filePath) - if (typeof entryPath !== 'string' || entryPath === '') return false - return isWithinDirectory(entryPath, targetPath) - } -} - -function isWithinDirectory(filePath: string, directoryPath: string): boolean { - const relative = path.relative(directoryPath, filePath) - if (relative === '') return true - return !relative.startsWith('..') && !path.isAbsolute(relative) -} - -function containsWhitespace(value: string): boolean { - return /\s/.test(value) -} - -function looksLikeGlob(value: string): boolean { - return GLOB_CHARS_RE.test(value) -} - -function looksLikePath(value: string): boolean { - return value.includes('/') || value.includes('\\') -} +export {handleSearchTextTool, handleSearchRegexTool} from './search-text' +export {handleSearchFileTool} from './search-file' +export {handleSearchSymbolTool} from './search-symbol' diff --git a/build/mcp-servers/ij-proxy/proxy-tools/handlers/test-helpers.ts b/build/mcp-servers/ij-proxy/proxy-tools/handlers/test-helpers.ts index e55cc2d21d93..caaaa3453897 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/handlers/test-helpers.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/handlers/test-helpers.ts @@ -1,5 +1,6 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +import {strictEqual} from 'node:assert/strict' import type {ToolArgs, UpstreamToolCaller} from '../types' interface ToolCall { @@ -31,6 +32,11 @@ export function createMockToolCaller(responders: ResponderMap = {}): { return {callUpstreamTool, calls} } +export function assertSingleCall(calls: ToolCall[]): ToolCall { + strictEqual(calls.length, 1) + return calls[0] +} + export function createSeededRng(seed: number): () => number { let state = seed >>> 0 return () => { diff --git a/build/mcp-servers/ij-proxy/proxy-tools/registry.ts b/build/mcp-servers/ij-proxy/proxy-tools/registry.ts index 711156a91034..e69484f3f7cc 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/registry.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/registry.ts @@ -5,7 +5,7 @@ import {handleEditTool} from './handlers/edit' import {handleListDirTool} from './handlers/list-dir' import {handleReadTool} from './handlers/read' import {handleRenameTool} from './handlers/rename' -import {handleSearchTool} from './handlers/search' +import {handleSearchFileTool, handleSearchRegexTool, handleSearchSymbolTool, handleSearchTextTool} from './handlers/search' import {handleWriteTool} from './handlers/write' import { createApplyPatchSchema, @@ -13,29 +13,26 @@ import { createListDirSchema, createReadSchema, createRenameSchema, - createSearchSchema, + createSearchFileSchema, + createSearchRegexSchema, + createSearchSymbolSchema, + createSearchTextSchema, createWriteSchema } from './schemas' -import type {SearchCapabilities, ToolArgs, ToolInputSchema, ToolSpecLike, UpstreamToolCaller} from './types' +import type {ReadCapabilities, SearchCapabilities, ToolArgs, ToolInputSchema, ToolSpecLike, UpstreamToolCaller} from './types' export const TOOL_MODES = { CODEX: 'codex', CC: 'cc' } as const -export const SEARCH_TOOL_MODES = { - AUTO: 'auto', - SEARCH: 'search', - LEGACY: 'legacy' -} as const - type ToolMode = typeof TOOL_MODES[keyof typeof TOOL_MODES] -export type SearchToolMode = typeof SEARCH_TOOL_MODES[keyof typeof SEARCH_TOOL_MODES] interface ToolContext { projectPath: string callUpstreamTool: UpstreamToolCaller searchCapabilities: SearchCapabilities + readCapabilities: ReadCapabilities } type ToolHandler = (args: ToolArgs) => Promise @@ -53,17 +50,17 @@ interface ToolVariant { expose?: ToolExpose } -export const BLOCKED_TOOL_NAMES = new Set(['create_new_file', 'execute_terminal_command', 'grep', 'find', 'glob']) +export const BLOCKED_TOOL_NAMES = new Set(['create_new_file', 'execute_terminal_command']) const EXTRA_REPLACED_TOOL_NAMES = [ 'search_in_files_by_text', 'search_in_files_by_regex', 'find_files_by_glob', 'find_files_by_name_keyword', + 'search', 'execute_terminal_command' ] const RENAME_TOOL_DESCRIPTION = 'Rename a symbol (class/function/variable/etc.) using IDE refactoring. Updates all references across the project; do not use edit/apply_patch for renames.' -const LEGACY_SEARCH_TOOL_DESCRIPTION = 'PRIMARY PROJECT SEARCH. Use this tool first. Returns JSON {items:[[path,line?,text?]], more?}. File-backed results only; output=files returns [path], output=entries returns [path,line,text] when available.' function resolveToolDescription(description: ToolDescription, context: ToolContext): string { return typeof description === 'function' ? description(context) : description @@ -75,10 +72,6 @@ function resolveToolExpose(expose: ToolExpose | undefined, context: ToolContext) return expose !== false } -function shouldExposeLegacySearch({searchCapabilities}: ToolContext): boolean { - return searchCapabilities.mode === SEARCH_TOOL_MODES.LEGACY || !searchCapabilities.hasUpstreamSearch -} - function buildToolSpec( name: string, description: ToolDescription, @@ -98,36 +91,99 @@ const TOOL_VARIANTS: ToolVariant[] = [ name: 'read_file', description: 'Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes.', schemaFactory: () => createReadSchema(true), - handlerFactory: ({projectPath, callUpstreamTool}) => (args) => - handleReadTool(args, projectPath, callUpstreamTool, {format: 'numbered'}), - upstreamNames: ['get_file_text_by_path'] + handlerFactory: ({projectPath, callUpstreamTool, readCapabilities}) => (args) => + handleReadTool(args, projectPath, callUpstreamTool, readCapabilities, {format: 'numbered'}), + upstreamNames: ['get_file_text_by_path'], + expose: ({readCapabilities}) => !readCapabilities.hasReadFile }, { mode: TOOL_MODES.CC, name: 'read', description: 'Read a local file using absolute or project-relative paths. Returns raw text.', schemaFactory: () => createReadSchema(false), - handlerFactory: ({projectPath, callUpstreamTool}) => (args) => - handleReadTool(args, projectPath, callUpstreamTool, {format: 'raw'}), + handlerFactory: ({projectPath, callUpstreamTool, readCapabilities}) => (args) => + handleReadTool(args, projectPath, callUpstreamTool, readCapabilities, {format: 'raw'}), upstreamNames: ['get_file_text_by_path'] }, { mode: TOOL_MODES.CODEX, - name: 'search', - description: LEGACY_SEARCH_TOOL_DESCRIPTION, - schemaFactory: ({searchCapabilities}) => createSearchSchema(searchCapabilities), + name: 'search_text', + description: 'Search for a text substring in project files.', + schemaFactory: () => createSearchTextSchema(), handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => - handleSearchTool(args, projectPath, callUpstreamTool, searchCapabilities), - expose: shouldExposeLegacySearch + handleSearchTextTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_text'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchText && searchCapabilities.supportsText }, { mode: TOOL_MODES.CC, - name: 'search', - description: LEGACY_SEARCH_TOOL_DESCRIPTION, - schemaFactory: ({searchCapabilities}) => createSearchSchema(searchCapabilities), + name: 'search_text', + description: 'Search for a text substring in project files.', + schemaFactory: () => createSearchTextSchema(), handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => - handleSearchTool(args, projectPath, callUpstreamTool, searchCapabilities), - expose: shouldExposeLegacySearch + handleSearchTextTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_text'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchText && searchCapabilities.supportsText + }, + { + mode: TOOL_MODES.CODEX, + name: 'search_regex', + description: 'Search for a regular expression in project files.', + schemaFactory: () => createSearchRegexSchema(), + handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => + handleSearchRegexTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_regex'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchRegex && searchCapabilities.supportsRegex + }, + { + mode: TOOL_MODES.CC, + name: 'search_regex', + description: 'Search for a regular expression in project files.', + schemaFactory: () => createSearchRegexSchema(), + handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => + handleSearchRegexTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_regex'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchRegex && searchCapabilities.supportsRegex + }, + { + mode: TOOL_MODES.CODEX, + name: 'search_file', + description: 'Search for files using a glob pattern.', + schemaFactory: () => createSearchFileSchema(), + handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => + handleSearchFileTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_file'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchFile && searchCapabilities.supportsFile + }, + { + mode: TOOL_MODES.CC, + name: 'search_file', + description: 'Search for files using a glob pattern.', + schemaFactory: () => createSearchFileSchema(), + handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => + handleSearchFileTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_file'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchFile && searchCapabilities.supportsFile + }, + { + mode: TOOL_MODES.CODEX, + name: 'search_symbol', + description: 'Search for symbols (classes, methods, fields) by name.', + schemaFactory: () => createSearchSymbolSchema(), + handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => + handleSearchSymbolTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_symbol'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchSymbol && searchCapabilities.supportsSymbol + }, + { + mode: TOOL_MODES.CC, + name: 'search_symbol', + description: 'Search for symbols (classes, methods, fields) by name.', + schemaFactory: () => createSearchSymbolSchema(), + handlerFactory: ({projectPath, callUpstreamTool, searchCapabilities}) => (args) => + handleSearchSymbolTool(args, projectPath, callUpstreamTool, searchCapabilities), + upstreamNames: ['search_symbol'], + expose: ({searchCapabilities}) => !searchCapabilities.hasSearchSymbol && searchCapabilities.supportsSymbol }, { mode: TOOL_MODES.CODEX, @@ -197,12 +253,6 @@ function isExposedVariantByDefault(tool: ToolVariant): boolean { return tool.expose !== false } -export function buildProxyToolSpecs(mode: ToolMode, context: ToolContext): ToolSpecLike[] { - return getProxyToolVariants(mode) - .filter((tool) => isExposedVariant(tool, context)) - .map((tool) => buildToolSpec(tool.name, tool.description, tool.schemaFactory(context), context)) -} - export function buildProxyToolingData(mode: ToolMode, context: ToolContext): { proxyToolSpecs: ToolSpecLike[] proxyToolNames: Set @@ -231,6 +281,7 @@ export function getReplacedToolNames() { for (const tool of TOOL_VARIANTS) { if (!tool.upstreamNames) continue for (const name of tool.upstreamNames) { + if (name === tool.name) continue replaced.add(name) } } diff --git a/build/mcp-servers/ij-proxy/proxy-tools/schemas.ts b/build/mcp-servers/ij-proxy/proxy-tools/schemas.ts index 950a0863bfcb..1106bb412b9e 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/schemas.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/schemas.ts @@ -1,6 +1,6 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -import type {SearchCapabilities, ToolInputSchema} from './types' +import type {ToolInputSchema} from './types' type JsonSchemaProperty = Record @@ -13,18 +13,6 @@ function objectSchema(properties: Record, required?: } } -function enumSchema(values: string[], description: string): JsonSchemaProperty { - const unique = Array.from(new Set(values)).filter((value) => value) - const schema: JsonSchemaProperty = { - type: 'string', - description - } - if (unique.length > 0) { - schema.enum = unique - } - return schema -} - export function createReadSchema(includeIndentation: boolean): ToolInputSchema { const properties = { file_path: { @@ -141,55 +129,53 @@ export function createListDirSchema(): ToolInputSchema { ) } -export function createSearchSchema(capabilities: SearchCapabilities): ToolInputSchema { - const targetValues = ['auto'] - if (capabilities.supportsSymbol) targetValues.push('symbol') - if (capabilities.supportsFile) targetValues.push('file') - if (capabilities.supportsText) targetValues.push('text') +function createSearchSchema(qDescription: string): ToolInputSchema { + return objectSchema( + { + q: { + type: 'string', + description: qDescription + }, + paths: { + type: 'array', + description: 'Optional list of project-relative glob patterns (supports ! excludes).', + items: { + type: 'string' + } + }, + limit: { + type: 'number', + description: 'Maximum number of results to return.' + } + }, + ['q'] + ) +} - const queryTypes = ['text'] - if (capabilities.supportsRegex) queryTypes.push('regex') - if (capabilities.supportsFileGlob) queryTypes.push('glob') +export function createSearchTextSchema(): ToolInputSchema { + return createSearchSchema('Text substring to search for.') +} - const properties: Record = { - query: { - type: 'string', - description: 'Search query text.' - }, - target: enumSchema( - targetValues, - 'Search target: "auto" (default), "symbol", "file", or "text".' - ), - path: { - type: 'string', - description: 'Optional base directory (absolute or project-relative).' - }, - file_mask: { - type: 'string', - description: 'Optional filename mask (e.g. "*.kt") for text searches.' - }, - case_sensitive: { - type: 'boolean', - description: 'Case-sensitive text search (default: true).' - }, - max_results: { - type: 'number', - description: 'Maximum number of results to return.' - }, - output: enumSchema( - ['entries', 'files'], - 'Output mode: "entries" (default for text/symbol) or "files" (default for file searches).' - ) - } +export function createSearchRegexSchema(): ToolInputSchema { + return createSearchSchema('Regular expression pattern to search for.') +} - if (queryTypes.length > 1) { - properties.query_type = enumSchema( - queryTypes, - 'Query type: "text" (default), "regex" (text searches), or "glob" (file searches).' - ) - } +export function createSearchFileSchema(): ToolInputSchema { + const base = createSearchSchema('Glob pattern to match file paths.') + return objectSchema( + { + ...base.properties, + includeExcluded: { + type: 'boolean', + description: 'Whether to include excluded/ignored files in results.' + } + }, + base.required + ) +} - return objectSchema(properties, ['query']) +export function createSearchSymbolSchema(): ToolInputSchema { + return createSearchSchema('Symbol query text (class, method, field, etc.).') } export function createApplyPatchSchema(): ToolInputSchema { diff --git a/build/mcp-servers/ij-proxy/proxy-tools/shared.ts b/build/mcp-servers/ij-proxy/proxy-tools/shared.ts index fbb4333848e1..8772b01f4faf 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/shared.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/shared.ts @@ -2,7 +2,7 @@ import path from 'node:path' import {z, type ZodType} from 'zod' -import type {SearchEntry, ToolResultLike, UpstreamToolCaller} from './types' +import type {SearchEntry, SearchItem, ToolResultLike, UpstreamToolCaller} from './types' export const TRUNCATION_MARKER = '<<<...content truncated...>>>' const FULL_READ_MAX_LINES = 200_000 @@ -14,18 +14,11 @@ export interface ResolvedPath { export type TruncateMode = 'NONE' | 'START' | 'END' -export type SearchItem = [string] | [string, number] | [string, number, string] - export interface ReadFileTextOptions { maxLinesCount?: number | null truncateMode?: TruncateMode | null } -export interface SplitLinesResult { - lines: string[] - trailingNewline: boolean -} - const nonEmptyStringSchema = z.string().refine((value) => value.trim() !== '', { message: 'must be a non-empty string' }) @@ -68,18 +61,6 @@ export function resolvePathInProject(projectPath: string, inputPath: unknown, la return {absolute, relative} } -export function resolveSearchPath(projectPath: string, inputPath: unknown): ResolvedPath { - if (inputPath === undefined || inputPath === null) { - return {absolute: projectPath, relative: ''} - } - return resolvePathInProject(projectPath, inputPath, 'path') -} - -export function looksLikeFilePath(rawPath: string, relativePath: string): boolean { - if (rawPath.endsWith(path.sep) || rawPath.endsWith('/') || rawPath.endsWith('\\')) return false - return path.extname(relativePath) !== '' -} - export function normalizeEntryPath(projectPath: string, filePath: T): T extends string ? string : T { if (typeof filePath !== 'string' || filePath === '') return filePath as T extends string ? string : T if (path.isAbsolute(filePath)) return filePath as T extends string ? string : T @@ -121,24 +102,30 @@ function isRecord(value: unknown): value is Record { } function coerceSearchItem(value: unknown): SearchItem | null { - if (typeof value === 'string') return [value] + if (typeof value === 'string') return {filePath: value} if (Array.isArray(value)) { if (value.length === 0 || value.length > 3) return null if (typeof value[0] !== 'string') return null - const line = typeof value[1] === 'number' ? value[1] : undefined - const text = typeof value[2] === 'string' ? value[2] : undefined - if (line === undefined) return [value[0]] - if (text === undefined) return [value[0], line] - return [value[0], line, text] + const item: SearchItem = {filePath: value[0]} + if (typeof value[1] === 'number') { + item.lineNumber = value[1] + if (typeof value[2] === 'string') { + item.lineText = value[2] + } + } + return item } if (isRecord(value)) { const filePath = typeof value.filePath === 'string' ? value.filePath : null if (!filePath) return null - const lineNumber = typeof value.lineNumber === 'number' ? value.lineNumber : undefined - const lineText = typeof value.lineText === 'string' ? value.lineText : undefined - if (lineNumber === undefined) return [filePath] - if (lineText === undefined) return [filePath, lineNumber] - return [filePath, lineNumber, lineText] + const item: SearchItem = {filePath} + if (typeof value.lineNumber === 'number') { + item.lineNumber = value.lineNumber + } + if (typeof value.lineText === 'string') { + item.lineText = value.lineText + } + return item } return null } @@ -170,26 +157,16 @@ function extractItemsFromValue(value: unknown): SearchItem[] | null { function itemsToEntries(items: SearchItem[]): SearchEntry[] { return items.map((item) => ({ - filePath: item[0], - lineNumber: item.length > 1 ? item[1] : undefined, - lineText: item.length > 2 ? item[2] : undefined + filePath: item.filePath, + lineNumber: item.lineNumber, + lineText: item.lineText })) } export function extractItems(result: unknown): SearchItem[] { const structured = extractStructuredContent(result) const fromStructured = extractItemsFromValue(structured) - if (fromStructured) return fromStructured - const text = extractTextFromResult(result) - if (!text) return [] - const trimmed = text.trim() - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return [] - try { - const parsed = JSON.parse(trimmed) - return extractItemsFromValue(parsed) ?? [] - } catch { - return [] - } + return fromStructured ?? [] } function coerceEntries(value: unknown): SearchEntry[] | null { @@ -224,17 +201,7 @@ function extractResultsMapFromValue(value: unknown): Record | null { const structured = extractStructuredContent(result) - const fromStructured = extractResultsMapFromValue(structured) - if (fromStructured) return fromStructured - const text = extractTextFromResult(result) - if (!text) return null - const trimmed = text.trim() - if (!trimmed.startsWith('{')) return null - try { - return extractResultsMapFromValue(JSON.parse(trimmed)) - } catch { - return null - } + return extractResultsMapFromValue(structured) } export function extractFileList(result: unknown): string[] { @@ -243,23 +210,16 @@ export function extractFileList(result: unknown): string[] { return extractFileListFromResults(resultsMap) } const structured = extractStructuredContent(result) - if (structured) { - const structuredRecord = structured as Record - if (Array.isArray(structuredRecord.items)) { - return extractItems(result).map((item) => item[0]) - } - if (Array.isArray(structuredRecord.files)) return structuredRecord.files as string[] - if (Array.isArray(structured)) return structured as string[] - } - const text = extractTextFromResult(result) - if (!text) return [] - try { - const parsed = JSON.parse(text) - if (Array.isArray(parsed.files)) return parsed.files - if (Array.isArray(parsed)) return parsed - } catch { - return [] + if (!structured) return [] + if (Array.isArray(structured)) { + const items = extractItemsFromValue(structured) + if (items) return items.map((item) => item.filePath) + return structured as string[] } + const structuredRecord = structured as Record + const items = extractItemsFromValue(structuredRecord) + if (items) return items.map((item) => item.filePath) + if (Array.isArray(structuredRecord.files)) return structuredRecord.files as string[] return [] } @@ -279,18 +239,6 @@ export function extractEntries(result: unknown): SearchEntry[] { return structured as SearchEntry[] } } - const text = extractTextFromResult(result) - if (!text) return [] - try { - const parsed = JSON.parse(text) - if (Array.isArray(parsed.entries)) return parsed.entries - if (Array.isArray(parsed.results)) return parsed.results - const fromItems = extractItemsFromValue(parsed) - if (fromItems) return itemsToEntries(fromItems) - if (Array.isArray(parsed)) return parsed - } catch { - return [] - } return [] } @@ -349,36 +297,3 @@ export function splitLines(text: string): string[] { } return lines } - -export function splitLinesWithTrailing(text: string): SplitLinesResult { - const normalized = normalizeLineEndings(text) - const trailingNewline = normalized.endsWith('\n') - const lines = normalized.split('\n') - if (trailingNewline) { - lines.pop() - } - return {lines, trailingNewline} -} - -export function countOccurrences(haystack: string, needle: string): number { - if (needle.length === 0) return 0 - let count = 0 - let index = 0 - while (true) { - const next = haystack.indexOf(needle, index) - if (next === -1) break - count += 1 - index = next + needle.length - } - return count -} - -export function replaceFirst(text: string, oldString: string, newString: string): string | null { - const index = text.indexOf(oldString) - if (index === -1) return null - return text.slice(0, index) + newString + text.slice(index + oldString.length) -} - -export function replaceAll(text: string, oldString: string, newString: string): string { - return text.split(oldString).join(newString) -} diff --git a/build/mcp-servers/ij-proxy/proxy-tools/tooling.ts b/build/mcp-servers/ij-proxy/proxy-tools/tooling.ts index d0198518abc6..e5074b445e25 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/tooling.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/tooling.ts @@ -1,23 +1,17 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -import {buildProxyToolingData, SEARCH_TOOL_MODES, TOOL_MODES} from './registry' -import type {SearchCapabilities, ToolArgs, ToolSpecLike, UpstreamToolCaller} from './types' +import {buildProxyToolingData, TOOL_MODES} from './registry' +import type {ReadCapabilities, SearchCapabilities, ToolArgs, ToolSpecLike, UpstreamToolCaller} from './types' export {TOOL_MODES} from './registry' type ToolMode = typeof TOOL_MODES[keyof typeof TOOL_MODES] -type SearchToolMode = typeof SEARCH_TOOL_MODES[keyof typeof SEARCH_TOOL_MODES] export interface ToolModeInfo { mode: ToolMode warning?: string } -export interface SearchToolModeInfo { - mode: SearchToolMode - warning?: string -} - export function resolveToolMode(rawValue: unknown): ToolModeInfo { if (rawValue === undefined || rawValue === null || rawValue === '') { return {mode: TOOL_MODES.CODEX} @@ -35,75 +29,73 @@ export function resolveToolMode(rawValue: unknown): ToolModeInfo { } } -export function resolveSearchToolMode(rawValue: unknown): SearchToolModeInfo { - if (rawValue === undefined || rawValue === null || rawValue === '') { - return {mode: SEARCH_TOOL_MODES.AUTO} - } - const normalized = String(rawValue).trim().toLowerCase() - if (normalized === '' || normalized === SEARCH_TOOL_MODES.AUTO) { - return {mode: SEARCH_TOOL_MODES.AUTO} - } - if (normalized === SEARCH_TOOL_MODES.LEGACY || normalized === 'legacy' || normalized === 'grep' || normalized === 'false' || normalized === '0') { - return {mode: SEARCH_TOOL_MODES.LEGACY} - } - if (normalized === SEARCH_TOOL_MODES.SEARCH || normalized === 'true' || normalized === '1' || normalized === 'semantic') { - return {mode: SEARCH_TOOL_MODES.SEARCH} - } - return { - mode: SEARCH_TOOL_MODES.AUTO, - warning: `Unknown JETBRAINS_MCP_SEARCH_TOOL '${rawValue}', defaulting to auto.` - } +const DISABLE_NEW_SEARCH_ENV = 'JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH' + +function isEnvFlagEnabled(name: string): boolean { + const raw = process.env[name] + if (!raw) return false + const normalized = raw.trim().toLowerCase() + return normalized !== '' && normalized !== '0' && normalized !== 'false' } export function resolveSearchCapabilities( - modeInfo: SearchToolModeInfo, upstreamTools: ToolSpecLike[] | undefined -): {capabilities: SearchCapabilities; warning?: string} { +): {capabilities: SearchCapabilities} { const names = new Set() for (const tool of upstreamTools ?? []) { const name = typeof tool?.name === 'string' ? tool.name : '' if (name) names.add(name) } + const disableNewSearch = isEnvFlagEnabled(DISABLE_NEW_SEARCH_ENV) const hasToolInfo = (upstreamTools ?? []).length > 0 - const hasUpstreamSearch = names.has('search') - const supportsRegex = hasToolInfo ? names.has('search_in_files_by_regex') : true - const supportsText = hasToolInfo ? (names.has('search_in_files_by_text') || supportsRegex) : true - const supportsFileGlob = hasToolInfo ? names.has('find_files_by_glob') : true - const supportsFileName = hasToolInfo ? names.has('find_files_by_name_keyword') : true - const supportsFile = supportsFileGlob || supportsFileName - const supportsSymbol = modeInfo.mode !== SEARCH_TOOL_MODES.LEGACY && hasUpstreamSearch + const hasSearchText = !disableNewSearch && names.has('search_text') + const hasSearchRegex = !disableNewSearch && names.has('search_regex') + const hasSearchFile = !disableNewSearch && names.has('search_file') + const hasSearchSymbol = names.has('search_symbol') + const supportsText = hasSearchText || (hasToolInfo ? names.has('search_in_files_by_text') : true) + const supportsRegex = hasSearchRegex || (hasToolInfo ? names.has('search_in_files_by_regex') : true) + const supportsFile = hasSearchFile || (hasToolInfo ? names.has('find_files_by_glob') : true) + const supportsSymbol = hasSearchSymbol const capabilities: SearchCapabilities = { - mode: modeInfo.mode, - hasUpstreamSearch, + hasSearchText, + hasSearchRegex, + hasSearchFile, + hasSearchSymbol, supportsSymbol, supportsText, supportsRegex, - supportsFile, - supportsFileGlob, - supportsFileName + supportsFile } - if (modeInfo.mode === SEARCH_TOOL_MODES.SEARCH && hasToolInfo && !hasUpstreamSearch) { - return { - capabilities, - warning: 'JETBRAINS_MCP_SEARCH_TOOL=search requested, but upstream search is unavailable; falling back to legacy search.' - } - } return {capabilities} } +export function resolveReadCapabilities( + upstreamTools: ToolSpecLike[] | undefined +): {capabilities: ReadCapabilities} { + const names = new Set() + for (const tool of upstreamTools ?? []) { + const name = typeof tool?.name === 'string' ? tool.name : '' + if (name) names.add(name) + } + + return {capabilities: {hasReadFile: names.has('read_file')}} +} + export function createProxyTooling({ projectPath, callUpstreamTool, toolMode, - searchCapabilities + searchCapabilities, + readCapabilities }: { projectPath: string callUpstreamTool: UpstreamToolCaller toolMode: ToolMode searchCapabilities: SearchCapabilities + readCapabilities: ReadCapabilities }): { proxyToolSpecs: ToolSpecLike[] proxyToolNames: Set @@ -114,7 +106,8 @@ export function createProxyTooling({ const {proxyToolSpecs, proxyToolNames, handlers} = buildProxyToolingData(resolvedMode, { projectPath, callUpstreamTool, - searchCapabilities + searchCapabilities, + readCapabilities }) async function runProxyToolCall(toolName: string, args: ToolArgs): Promise { diff --git a/build/mcp-servers/ij-proxy/proxy-tools/types.ts b/build/mcp-servers/ij-proxy/proxy-tools/types.ts index 3a410bfa6dbf..c3157c9dbf20 100644 --- a/build/mcp-servers/ij-proxy/proxy-tools/types.ts +++ b/build/mcp-servers/ij-proxy/proxy-tools/types.ts @@ -24,17 +24,25 @@ export interface SearchEntry { [key: string]: unknown } -export type SearchCapabilityMode = 'auto' | 'search' | 'legacy' +export interface SearchItem { + filePath: string + lineNumber?: number + lineText?: string +} export interface SearchCapabilities { - mode: SearchCapabilityMode - hasUpstreamSearch: boolean + hasSearchText: boolean + hasSearchRegex: boolean + hasSearchFile: boolean + hasSearchSymbol: boolean supportsSymbol: boolean supportsText: boolean supportsRegex: boolean supportsFile: boolean - supportsFileGlob: boolean - supportsFileName: boolean +} + +export interface ReadCapabilities { + hasReadFile: boolean } export interface ToolInputSchema { diff --git a/build/mcp-servers/ij-proxy/search.md b/build/mcp-servers/ij-proxy/search.md new file mode 100644 index 000000000000..98dd7c6a7218 --- /dev/null +++ b/build/mcp-servers/ij-proxy/search.md @@ -0,0 +1,110 @@ +# ij-proxy Search: API Shape and Compatibility + +This document describes the search tool surface we expose to clients and how ij-proxy bridges differences between JetBrains MCP versions. + +## Goals + +- Provide a small, predictable search API for clients. +- Keep behavior stable across IDE versions. +- Normalize results and apply workarounds for known upstream limitations. + +## Client-Facing Search Tools (Proxy Shape) + +When the upstream server does not provide `search_*` tools (or when `JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH` is enabled), ij-proxy exposes four search tools with minimal parameters: + +- `search_text(q, paths?, limit?)` +- `search_regex(q, paths?, limit?)` +- `search_file(q, paths?, includeExcluded?, limit?)` +- `search_symbol(q, paths?, limit?)` + +Common semantics: + +- `q` is the query. It is always a literal substring for `search_text`, always a regular expression for `search_regex`, and always a glob for `search_file`. +- `paths` is an optional list of project-relative glob filters (see Path Filters below). +- `limit` is the max number of results to return. +- `includeExcluded` (search_file only) controls whether excluded/ignored files are included when the IDE supports it. + +Output shape: + +- JSON with `items` and optional `more`. +- Each item is an object with `filePath` and optional `lineNumber`/`lineText`. +- `search_file` returns only `filePath`. +- `search_text` / `search_regex` return snippets (`lineNumber`, `lineText`) when available. +- `search_symbol` returns file paths and snippets when available. + +## Tool Exposure and Modes + +Search tools are exposed in both tool modes (`JETBRAINS_MCP_TOOL_MODE=codex` or `cc`) with the same names and parameters when ij-proxy provides the fallback shims. + +You can force legacy search behavior by setting `JETBRAINS_MCP_PROXY_DISABLE_NEW_SEARCH` to any non-empty value except `0` or `false`. +When enabled, ij-proxy will ignore upstream `search_*` tools and only use the legacy search APIs when present. + +- If the upstream server already exposes `search_*` tools, ij-proxy passes them through unchanged (schema + behavior) and does not expose proxy shims for those names. +- When upstream `search_*` tools are absent (or disabled), `search_text`, `search_regex`, and `search_file` are exposed via the proxy shims. +- `search_symbol` is only exposed when the upstream server provides it (ij-proxy does not emulate symbol search). + +When legacy upstream tools are present, ij-proxy hides them and presents the unified `search_*` surface instead: + +- `search_in_files_by_text` -> `search_text` +- `search_in_files_by_regex` -> `search_regex` +- `find_files_by_glob` -> `search_file` + +## Path Filters (`paths`) + +`paths` is a list of glob patterns relative to the project root. + +Rules: + +- Supports `!` excludes (negation). +- Trailing `/` expands to `**` (e.g., `src/` => `src/**`). +- Patterns without `/` are treated as `**/pattern`. +- Absolute paths are normalized to project-relative when they are inside the project; otherwise they are rejected. +- Empty strings are ignored. + +Examples: + +- `paths = ["src/**", "!**/test/**"]` +- `paths = ["**/*.kt"]` +- `paths = ["platform/"]` + +## How ij-proxy and JetBrains MCP Complement Each Other + +JetBrains MCP is the source of truth for indexing/search inside the IDE. ij-proxy does not implement its own index; it adapts the upstream API. + +ij-proxy provides: + +- A stable client surface even when upstream tool names or parameters change. +- Backward compatibility for older IDE builds. +- Result normalization and path filtering that are consistent across versions. + +### New IDE Versions (Preferred) + +When the upstream server exposes the new search tools directly, ij-proxy passes them through unchanged. In this mode, tool parameters and output shape are defined by the upstream server, and ij-proxy does not apply proxy-side path filtering or result normalization. + +### Older IDE Versions (Compatibility) + +If the new tools are not present, ij-proxy emulates them using legacy tools: + +- `search_text` -> `search_in_files_by_text` +- `search_regex` -> `search_in_files_by_regex` +- `search_file` -> `find_files_by_glob` +- `search_symbol` is only exposed when the upstream server provides `search_symbol`. + +For `search_file`, `includeExcluded=true` maps to `addExcluded` on legacy `find_files_by_glob` when available. + +When using the proxy shims, ij-proxy applies `paths` filters locally so clients see consistent behavior across IDE versions. + +## Workarounds and Normalization + +Some upstream legacy tools have known limitations. ij-proxy compensates for them when using the proxy shims: + +- Regex directory scope: some IDE versions ignore `directoryToSearch` for regex searches. ij-proxy always post-filters regex results by path to keep results correct. +- Path-aware globs: upstream file masks match filenames only. For path-aware patterns (like `src/**/Foo*.java`), ij-proxy applies glob matching on full paths after the search. + +These workarounds are intentionally kept in ij-proxy so clients get stable behavior without needing version checks. + +## Relation to Legacy Tools + +The `find` tool still exists for older client conventions, but the new search surface is the primary API going forward. + +If you are implementing a client, use the new tools described above for predictable behavior across IDE versions. diff --git a/build/mcp-servers/ij-proxy/stream-transport.ts b/build/mcp-servers/ij-proxy/stream-transport.ts index 98dea9db5b99..8aabdd684fb1 100644 --- a/build/mcp-servers/ij-proxy/stream-transport.ts +++ b/build/mcp-servers/ij-proxy/stream-transport.ts @@ -14,7 +14,7 @@ interface PortCandidate { kind: 'preferred' | 'scan' } -interface StreamTransportOptions { +export interface StreamTransportOptions { explicitUrl?: string preferredPorts?: number[] portScanStart: number @@ -31,6 +31,18 @@ interface StreamTransportOptions { probeHost?: string } +export interface McpStreamTransport { + sessionId: string | undefined + onmessage?: (message: TransportMessage, extra?: unknown) => void + onerror?: (error: Error) => void + onclose?: () => void + start: () => Promise + send: (message: TransportMessage, options?: TransportSendOptions) => Promise + close: () => Promise + setProtocolVersion: (version: string) => void + resetTransport: (reason: unknown) => Promise +} + interface QueueEntry { message: TransportMessage options: TransportSendOptions @@ -82,7 +94,7 @@ function normalizePortList( return candidates } -class StreamTransport { +class StreamTransportImpl implements McpStreamTransport { _options: StreamTransportOptions _queue: QueueEntry[] _connectPromise: Promise | null @@ -143,7 +155,7 @@ class StreamTransport { } } - async _resetTransport(reason: unknown): Promise { + async resetTransport(reason: unknown): Promise { const warn = this._options.warn const message = reason instanceof Error ? reason.message : String(reason) if (warn) warn(`MCP stream session invalid; reconnecting. ${message}`) @@ -174,7 +186,7 @@ class StreamTransport { const err = error instanceof Error ? error : new Error(String(error)) if (!retried && isSessionNotFoundError(err)) { retried = true - await this._resetTransport(err) + await this.resetTransport(err) continue } if (this.onerror) this.onerror(err) @@ -352,8 +364,8 @@ export function createStreamTransport({ note, warn, probeHost = '127.0.0.1' -}: StreamTransportOptions): StreamTransport { - return new StreamTransport({ +}: StreamTransportOptions): McpStreamTransport { + return new StreamTransportImpl({ explicitUrl, preferredPorts, portScanStart, diff --git a/build/mcp-servers/ij-proxy/test-utils.ts b/build/mcp-servers/ij-proxy/test-utils.ts index e04edf682800..1c8afc2e5d38 100644 --- a/build/mcp-servers/ij-proxy/test-utils.ts +++ b/build/mcp-servers/ij-proxy/test-utils.ts @@ -43,6 +43,8 @@ interface FakeServerOptions { tools?: ToolSpecLike[] onToolCall?: ToolCallHandler responseMode?: 'json' | 'sse' + sessionId?: string + port?: number } type ProxyEnvFactory = (context: {fakeServer: FakeServerInstance}) => Record @@ -145,13 +147,13 @@ export const defaultUpstreamTools = [...DEFAULT_UPSTREAM_TOOL_NAMES].map((name) ) export async function startFakeMcpServer( - {tools = defaultUpstreamTools, onToolCall, responseMode = 'json'}: FakeServerOptions = {} + {tools = defaultUpstreamTools, onToolCall, responseMode = 'json', sessionId = 'test-session', port: requestedPort}: FakeServerOptions = {} ): Promise { const toolCallQueue: ToolCall[] = [] const toolCallWaiters: Array<(call: ToolCall) => void> = [] const sockets = new Set() - const sessionId = 'test-session' const responseModeValue = responseMode === 'sse' ? 'sse' : 'json' + let isClosed = false function enqueueToolCall(call: ToolCall): void { if (toolCallWaiters.length > 0) { @@ -241,6 +243,10 @@ export async function startFakeMcpServer( } async function listenWithFallback(): Promise { + if (typeof requestedPort === 'number' && Number.isFinite(requestedPort) && requestedPort > 0) { + await listenOnPort(requestedPort) + return + } try { await listenOnPort(0) return @@ -266,11 +272,11 @@ export async function startFakeMcpServer( await listenWithFallback() const address = httpServer.address() - const port = typeof address === 'object' && address ? address.port : 0 - debug(`fake server: listening on ${port}`) + const boundPort = typeof address === 'object' && address ? address.port : 0 + debug(`fake server: listening on ${boundPort}`) return { - port, + port: boundPort, waitForToolCall(): Promise { return new Promise((resolve) => { if (toolCallQueue.length > 0) { @@ -281,6 +287,8 @@ export async function startFakeMcpServer( }) }, async close(): Promise { + if (isClosed) return + isClosed = true await mcpServer.close() for (const socket of sockets) { socket.destroy() @@ -348,3 +356,30 @@ export async function withProxy( if (testDir) rmSync(testDir, {recursive: true, force: true}) } } + +export async function withStreamProxy( + options: { + proxyEnv?: Record + } = {}, + run: (context: {proxyClient: McpTestClient; testDir: string}) => Promise +): Promise { + let proxyClient: McpTestClient | undefined + let testDir: string | undefined + + try { + testDir = mkdtempSync(join(tmpdir(), 'ij-mcp-proxy-stream-')) + const proxy = startProxy(testDir, 64342, options.proxyEnv ?? {}) + proxyClient = new McpTestClient(proxy) + debug('setup: sending initialize (stream)') + await proxyClient.send('initialize', { + protocolVersion: '2024-11-05', + clientInfo: {name: 'test-client', version: '1.0.0'}, + capabilities: {} + }) + debug('setup: initialize complete (stream)') + await run({proxyClient, testDir}) + } finally { + if (proxyClient) await proxyClient.close() + if (testDir) rmSync(testDir, {recursive: true, force: true}) + } +} diff --git a/build/mcp-servers/ij-proxy/workarounds.ts b/build/mcp-servers/ij-proxy/workarounds.ts index 1bb26fe9c306..5a60754e2953 100644 --- a/build/mcp-servers/ij-proxy/workarounds.ts +++ b/build/mcp-servers/ij-proxy/workarounds.ts @@ -2,7 +2,7 @@ type VersionParts = number[] -export interface ParsedIdeVersion { +interface ParsedIdeVersion { raw: string full?: VersionParts build?: VersionParts @@ -16,6 +16,7 @@ type ParsedVersionSpec = { const FULL_VERSION_RE = /\b\d{4}\.\d+(?:\.\d+){0,2}\b/ const BUILD_VERSION_RE = /\b\d{3}\.\d+(?:\.\d+)?\b/ const SNAPSHOT_BUILD_RE = /\b(\d{3})\.SNAPSHOT\b/i +const SNAPSHOT_BUILD_PART = Number.MAX_SAFE_INTEGER const ANY_VERSION_RE = /\d+(?:\.\d+)+/ const DISABLE_ALL_ENV = 'JETBRAINS_MCP_PROXY_DISABLE_WORKAROUNDS' const DISABLE_KEYS_ENV = 'JETBRAINS_MCP_PROXY_DISABLE_WORKAROUND_KEYS' @@ -28,8 +29,8 @@ export enum WorkaroundKey { } // Map workaround key -> version when fixed (empty string means not fixed yet). -export const WORKAROUND_FIXED_IN: Record = { - [WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored]: '261.SNAPSHOT' +const WORKAROUND_FIXED_IN: Record = { + [WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored]: '261.20247' } let currentIdeVersion: ParsedIdeVersion | null = null @@ -42,10 +43,6 @@ export function setIdeVersion(rawVersion: string | null | undefined): void { currentIdeVersion = parseIdeVersion(rawVersion) } -export function getIdeVersion(): ParsedIdeVersion | null { - return currentIdeVersion -} - export function shouldApplyWorkaround(key: WorkaroundKey): boolean { if (isWorkaroundDisabled(key)) { logDebug(`Workaround ${key} not used (disabled by env)`) @@ -93,7 +90,17 @@ function logDebug(message: string): void { function parseIdeVersion(raw: string): ParsedIdeVersion { const full = extractVersionParts(raw, FULL_VERSION_RE) - const build = extractVersionParts(raw, BUILD_VERSION_RE) + let build = extractVersionParts(raw, BUILD_VERSION_RE) + if (!build) { + // Treat SNAPSHOT builds as "newest" within their train, so fixed-in build thresholds disable workarounds. + const snapshotMatch = raw.match(SNAPSHOT_BUILD_RE) + if (snapshotMatch) { + const train = Number.parseInt(snapshotMatch[1], 10) + if (!Number.isNaN(train)) { + build = [train, SNAPSHOT_BUILD_PART] + } + } + } return { raw, full: full ?? undefined,