mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
(mcp-search) IJPL-224042 unified search (take 2 - 4 tools instead of 1)
GitOrigin-RevId: 4080c7be42b8e32245f1a4eabd0135fd6f2f6234
This commit is contained in:
committed by
intellij-monorepo-bot
parent
881b283252
commit
97e946bfae
+11
-4
@@ -47,14 +47,20 @@ Preserve IDE-serialized .iml files in canonical form. Do not:
|
||||
<!-- IF_TOOL:CODEX -->
|
||||
- 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`
|
||||
<!-- /IF_TOOL:CODEX -->
|
||||
<!-- IF_TOOL:CLAUDE -->
|
||||
- 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`
|
||||
<!-- /IF_TOOL:CLAUDE -->
|
||||
|
||||
@@ -72,15 +78,16 @@ Direct JetBrains MCP connection. Use when ijproxy unavailable.
|
||||
|
||||
### Client fallback (no MCP)
|
||||
<!-- IF_EDITION:ULTIMATE -->
|
||||
- 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.
|
||||
<!-- /IF_EDITION:ULTIMATE -->
|
||||
<!-- IF_EDITION:COMMUNITY -->
|
||||
- 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.
|
||||
<!-- /IF_EDITION:COMMUNITY -->
|
||||
|
||||
### 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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1754
-547
File diff suppressed because it is too large
Load Diff
@@ -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<string> {
|
||||
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<void> | 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<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
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<ToolSpecLike[]> {
|
||||
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<ToolSpecLike[]> {
|
||||
@@ -338,36 +374,40 @@ function makeToolError(text: unknown): ToolOutput {
|
||||
}
|
||||
|
||||
async function callUpstreamToolForClient(toolName: string, args: ToolArgs): Promise<unknown> {
|
||||
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<unknown> {
|
||||
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<string>
|
||||
blockedNames: Set<string> | Iterable<string> | undefined
|
||||
): ToolSpecLike[] {
|
||||
const blocked = new Set(blockedNames || [])
|
||||
const blocked = blockedNames instanceof Set ? blockedNames : new Set(blockedNames || [])
|
||||
const result: ToolSpecLike[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>
|
||||
: 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<FindFilesResult> {
|
||||
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<FindFilesResult> {
|
||||
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<string> {
|
||||
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')
|
||||
}
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, unknown> = {
|
||||
file_path: relative,
|
||||
offset,
|
||||
limit
|
||||
}
|
||||
if (mode === 'indentation') {
|
||||
upstreamArgs.mode = 'indentation'
|
||||
const indentationPayload: Record<string, unknown> = {
|
||||
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, {
|
||||
|
||||
@@ -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
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string, unknown> = {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<string, unknown>
|
||||
: null
|
||||
return {
|
||||
files,
|
||||
probablyHasMoreMatchingFiles: structuredRecord?.probablyHasMoreMatchingFiles === true,
|
||||
timedOut: structuredRecord?.timedOut === true
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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, '/')
|
||||
}
|
||||
@@ -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<string, unknown> = {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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<string, unknown>
|
||||
: 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, '/')
|
||||
}
|
||||
@@ -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<string> {
|
||||
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')
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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<string>()
|
||||
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<string> {
|
||||
if (!capabilities.supportsSymbol) {
|
||||
throw new Error('symbol search is not supported by this IDE version')
|
||||
}
|
||||
|
||||
const {relative} = resolveSearchPath(projectPath, options.pathArg)
|
||||
const toolArgs: Record<string, unknown> = {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, unknown> = {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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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<string, unknown>
|
||||
: 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'
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<unknown>
|
||||
@@ -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<string>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
@@ -13,18 +13,6 @@ function objectSchema(properties: Record<string, JsonSchemaProperty>, 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<string, JsonSchemaProperty> = {
|
||||
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 {
|
||||
|
||||
@@ -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<T>(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<string, unknown> {
|
||||
}
|
||||
|
||||
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<string, SearchEntry[
|
||||
|
||||
export function extractResultsMap(result: unknown): Record<string, SearchEntry[]> | 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<string>()
|
||||
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<string>()
|
||||
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<string>
|
||||
@@ -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<unknown> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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<void>
|
||||
send: (message: TransportMessage, options?: TransportSendOptions) => Promise<void>
|
||||
close: () => Promise<void>
|
||||
setProtocolVersion: (version: string) => void
|
||||
resetTransport: (reason: unknown) => Promise<void>
|
||||
}
|
||||
|
||||
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<void> | null
|
||||
@@ -143,7 +155,7 @@ class StreamTransport {
|
||||
}
|
||||
}
|
||||
|
||||
async _resetTransport(reason: unknown): Promise<void> {
|
||||
async resetTransport(reason: unknown): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -43,6 +43,8 @@ interface FakeServerOptions {
|
||||
tools?: ToolSpecLike[]
|
||||
onToolCall?: ToolCallHandler
|
||||
responseMode?: 'json' | 'sse'
|
||||
sessionId?: string
|
||||
port?: number
|
||||
}
|
||||
|
||||
type ProxyEnvFactory = (context: {fakeServer: FakeServerInstance}) => Record<string, string>
|
||||
@@ -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<FakeServerInstance> {
|
||||
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<void> {
|
||||
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<ToolCall> {
|
||||
return new Promise((resolve) => {
|
||||
if (toolCallQueue.length > 0) {
|
||||
@@ -281,6 +287,8 @@ export async function startFakeMcpServer(
|
||||
})
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
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<string, string>
|
||||
} = {},
|
||||
run: (context: {proxyClient: McpTestClient; testDir: string}) => Promise<void>
|
||||
): Promise<void> {
|
||||
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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, string> = {
|
||||
[WorkaroundKey.SearchInFilesByRegexDirectoryScopeIgnored]: '261.SNAPSHOT'
|
||||
const WORKAROUND_FIXED_IN: Record<WorkaroundKey, string> = {
|
||||
[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,
|
||||
|
||||
Reference in New Issue
Block a user