# Contributing to IDE Index MCP Server This document is the single source of truth for contributors — human or AI. Follow every rule here before opening a pull request. --- ## Quick start ### Local feedback loop (run these yourself) ```bash ./gradlew test -Ptier=unit # fast headless tier, no IntelliJ Platform (~20 s) ./gradlew test # everything, platform tests included (~40 s) ./gradlew runIde # launch sandboxed IDE with plugin installed ./scripts/check-pr.sh # pre-push validation — run before every push ``` Run the full suite before pushing. Platform tests are part of your local loop, not a CI-only luxury — they are where tool behavior is actually verified. ### CI / maintainer-only ```bash ./gradlew build # full build + tests + plugin artifact ./gradlew verifyPlugin # Marketplace compatibility check ``` ### Notes on the test commands `-Ptier=unit` / `-Ptier=platform` exist because Gradle's `--tests` flag has **no negation operator** and OR-combines repeated occurrences. Earlier revisions of this file documented `--tests "*Test" --tests "!*UnitTest*"` for "platform tests only"; that command silently ran the entire suite, because `*Test` already matches every `*UnitTest` class and `!*UnitTest*` is a literal pattern matching nothing. This file also used to claim the platform tests "time out on headless machines." That was never substantiated: CI runs `./gradlew check` on `ubuntu-latest` with no xvfb and no `DISPLAY`, and the full suite completes locally in about 40 seconds. Run them. The task is `verifyPlugin`, not `runPluginVerifier` — the latter does not exist under the IntelliJ Platform Gradle Plugin 2.x used here. --- ## PR rules ### CHANGELOG.md - **DO** add an entry under `## [Unreleased]` for every user-visible change. Use sections: `Added`, `Changed`, `Fixed`, `Removed`, `Breaking`. - **DO NOT** add release version entries (`## [x.y.z]`) — the maintainer creates those at merge time. - **DO NOT** re-list tools that already shipped in a previous release. After a rebase, check that already-released tool entries have not bled back into `[Unreleased]`. ### Files that must NOT appear in PRs | File | Reason | |------|--------| | `.idea/gradle.xml` | Contains local JDK name (`gradleJvm`); breaks on other machines | | `scripts/build-install.sh` | Local build helper; not for upstream | | `docs/pr-*.md` | Rename to drop the `pr-` prefix before submitting | ### Version bumps Do **not** change `pluginVersion` in `gradle.properties` unless explicitly requested. When requested, follow [SemVer](https://semver.org): | Change type | Version part | |-------------|-------------| | Bug fix / internal refactor | Patch (`x.y.Z`) | | New tool / new feature | Minor (`x.Y.0`) | | Breaking schema / transport change | Major (`X.0.0`) | ### Releasing (maintainer) Releases are published from GitHub — no manual Marketplace upload: 1. Merge a version-bump PR (`pluginVersion` in `gradle.properties`). Changelog entries may stay in `[Unreleased]` — the release pipeline moves them under the new version. 2. The Build workflow on `main` creates a draft GitHub Release carrying the pending change notes. 3. Press **Publish release** on the draft. The Release workflow signs the plugin, publishes it to JetBrains Marketplace, attaches the zip to the release, and — if entries were still in `[Unreleased]` — opens a `Changelog update - x.y.z` PR that moves them; merge it. Manually moving entries into a `## [x.y.z]` section in the bump PR also works: the pipeline detects the existing section and skips the changelog patch/PR. A `-beta.N` version suffix published as a GitHub **prerelease** goes to the Marketplace `beta` channel instead of stable. --- ## Adding a new tool — complete checklist Every item is required. CI will catch missing registrations and test count mismatches. ### Implementation - [ ] Extend `AbstractMcpTool`, implement `doExecute()` (never `execute()`) - [ ] Set `override val requiresPsiSync = false` unless the tool reads PSI indexes - [ ] Set `override val participatesInLifecycle = false` for infrastructure / observer tools - [ ] If the tool can block longer than ~45s, it MUST use the long-poll pattern (`LongPollRegistry` + `waitSeconds` + a poll id) — MCP clients kill any call at their own request timeout (60s in Claude Code) and the transport cannot stream progress. See "Long-Running Tools" in CLAUDE.md and `ide_run_tests`/`ide_build_project` for the shape. (tools that manage lifecycle state, or bulk-operate across all projects) - [ ] Use `SchemaBuilder` for `inputSchema` — never construct `JsonObject` manually - [ ] Add `project_path` via `.projectPath()` on the builder for any multi-project tool ### Registration - [ ] Add constant to `ToolNames.kt` in the correct group - [ ] Add constant to `ToolNames.ALL` in **strict alphabetical order by full string value** (compare the complete `ide_*` string character by character: `ide_release_all_projects` < `ide_release_project` (position 12: `a` < `p`) and both < `ide_restart` (position 6: `l` < `s`)). A sort test in `ConstantsUnitTest` will fail if the order is wrong. - [ ] Add opt-in tools to `McpSettings.DEFAULT_DISABLED_TOOLS` - [ ] Bump `ToolSettingsDefaults.CURRENT_SCHEMA_VERSION` and add a migration entry so users with existing persisted settings also get the new tool disabled by default - [ ] Register in `ToolRegistry.registerUniversalTools()` (or the appropriate method) ### Documentation - [ ] Add a row to the **`README.md` universal tools table** and update the tool count in the "The plugin provides **N MCP tools**" sentence - [ ] Add a section to `USAGE.md` with a parameters table and a request/response example - [ ] Add one-line entry to the tool inventory in `CLAUDE.md` - [ ] Add tool name to `SKILL.md` trigger list (frontmatter description) and disabled-tools section, both **in alphabetical order** - [ ] Add the tool to `src/main/resources/skill/ide-index-mcp/references/tools-reference.md` (the bundled detailed parameter/return reference linked from SKILL.md) **Quick consistency check** — after adding the tool, grep its name across all six locations and confirm each has an entry: `README.md`, `USAGE.md`, `CLAUDE.md`, `SKILL.md`, `tools-reference.md`, `ToolNames.kt` / `McpSettings.kt` / `ToolRegistry.kt`, and tests. ### Tests - [ ] Add unit tests in the appropriate `*UnitTest.kt` file: - tool name matches the `ToolNames` constant - required fields are present / absent as expected - opt-in tool appears in `McpSettings.DEFAULT_DISABLED_TOOLS` - legacy `McpSettings.State(settingsSchemaVersion = 0)` migration keeps the tool disabled - [ ] **Regenerate the golden tool manifest** — a new tool changes the snapshot, so `ToolManifestContractUnitTest` will fail until you do: ```bash ./gradlew test -Ptier=unit --tests "*ToolManifestContractUnitTest" -Dcontract.update=true ``` Then re-run without the flag and **review the diff to `src/test/resources/contract/tool-manifest.json` as part of the change**. The manifest is a contract with MCP clients: the diff should show exactly your new tool and nothing else. If it shows unrelated schema or description churn, something regressed — investigate before committing. Never regenerate it to make an unexplained failure go away. - [ ] Update `ConstantsUnitTest.testToolNamesAllContainsEveryConstant` — add the new constant and verify `ToolNames.ALL.size` still matches - [ ] Update `ToolExecutionIntegrationTest.testAllToolsRegistered` — add the new constant in the same alphabetical position as in `ToolNames.ALL` - [ ] Add a **behavior test that executes the tool** and asserts on its real result, not just its schema. A tool covered only by schema and registration assertions is a tool nobody has proven works — see `SyncFilesToolBehaviorTest` / `ProjectStatusToolBehaviorTest` for the shape, and extend `McpPlatformTestCase` so fixtures land on the real filesystem. - [ ] For opt-in features with a toggle (e.g. `lifecycleEnabled`): tests that exercise opt-in behaviour must enable the flag in `setUp()` and restore it in `tearDown()` ### Test quality rules - Assertions must **actually fail** if the implementation is deleted (no vacuous tests) - Do not simulate the system under test with a private helper and then assert on the helper - Do not leave placeholder tests with comments referencing non-existent code - Tests that simulate the system under test locally and assert on the simulation get deleted (a former `McpServerWatchdogTest` did exactly this; the real integration test, `KtorMcpServerWatchdogTest`, is the one that counts) - Never place a test class at a `PluginDetector` fallback FQN (the `fallbackClass` values in `PluginDetectors.kt`) — the detector's `Class.forName` fallback would then report that language plugin as available for the entire test fork. Use a duck-typed fake in the test's own package. Enforced by `PluginDetectorLeakUnitTest` and by `scripts/check-pr.sh` (test tree hygiene check). --- ## Code correctness review — mandatory before every push The checklist above covers registration and documentation. This section covers **code correctness** — the bugs that slip past unit tests because they only surface under real threading, real disposal, or real multi-language execution. These checks exist because the same categories of bugs have appeared in multiple PRs. Each item maps to a real production defect. Do not skip items because "it looks fine" — trace the actual execution path. ### Threading: trace every `doExecute` to PSI MCP tool calls arrive on Ktor coroutine worker threads — no read lock, not EDT. For every code path in your PR that touches PSI: - [ ] **PSI reads** are inside `suspendingReadAction { }` or `ReadAction.compute { }` - [ ] **PSI writes** are inside `edtAction { WriteCommandAction.runWriteCommandAction { } }` - [ ] **Processors that manage their own write actions** (e.g., `RenameProcessor.run()`, `Replacer.replaceAll()`) are called on EDT but **not** wrapped in an extra `WriteCommandAction` — double-wrapping deadlocks - [ ] **Line/column calculations** that depend on document length happen **inside** the write action, after the edit — not before, when the document was a different length How to verify: start at `doExecute()`, follow every call that eventually reaches a `PsiElement`, `PsiFile`, `Document`, or `PsiManager`. Each one must be in the correct threading context. `BasePlatformTestCase` runs on EDT with implicit read access, so **tests will not catch threading violations** — you must trace the production path manually. ### Reflection proxies: handle Object methods Every `Proxy.newProxyInstance` call must handle `equals`, `hashCode`, and `toString`. The default return of `null` causes `NullPointerException` when the proxy is stored in collections or disposed through `Disposer.dispose()`. ```kotlin // ✗ NPE when Disposer calls equals() during removal { _, method, args -> if (method.name == "onEvent") { ... }; null } // ✓ Safe { proxyObj, method, args -> when (method.name) { "equals" -> proxyObj === args?.get(0) "hashCode" -> System.identityHashCode(proxyObj) "toString" -> "MyListener-proxy" "onEvent" -> { ...; null } else -> null } } ``` - [ ] Every `Proxy.newProxyInstance` in the PR handles `equals`, `hashCode`, `toString` - [ ] `grep -rn "Proxy.newProxyInstance" src/main/` — check ALL existing proxies too, not just the ones you added ### Error handling: distinguish "unavailable" from "broken" When using reflection to access optional plugin APIs, the catch block must distinguish `ClassNotFoundException` (plugin not installed — expected, recoverable) from other exceptions (plugin present but call failed — unexpected, worth reporting). ```kotlin // ✗ Loses error information — caller can't tell if Maven is missing or if import crashed return try { ... } catch (_: Exception) { null } // ✓ Caller can distinguish and report appropriately catch (e: ClassNotFoundException) { return PluginUnavailable } catch (e: Exception) { return Failed(e.message) } ``` - [ ] Every `catch (_: Exception)` or `catch (_: Throwable)` — is the error type information actually unneeded, or is it being silently swallowed? ### Sibling consistency: same fix in all language resolvers When fixing a bug in one language resolver (Java, Kotlin, JavaScript/TypeScript), check whether the same pattern exists in sibling implementations. - [ ] Search for the same method name in `Java*Resolver`, `Kotlin*Resolver`, `JavaScript*Resolver` — does the fix apply to all of them? - [ ] Search for the same method name in `Java*Handler`, `Kotlin*Handler`, `Python*Handler`, `JavaScript*Handler`, etc. — same question ### Dead code: trace from interface to all implementations When removing a caller, check whether the method is still referenced anywhere. If not, remove it from the interface and all implementations. - [ ] Every method removed or added — does the interface still match all implementations? - [ ] `ide_find_references` on the method — zero callers means dead code ### Test honesty: conditional skips must be visible Tests that skip when an optional plugin is absent must use `Assume.assumeTrue()`, not early-return. Early-return silently passes — the test appears green but never ran. ```kotlin // ✗ Silently passes — CI shows green even though nothing was tested if (!pluginAvailable) return // ✓ CI shows "skipped" — the maintainer knows it didn't run Assume.assumeTrue("JavaScript plugin not available", pluginAvailable) ``` - [ ] Every conditional test skip uses `Assume.assumeTrue`, not `return` or `return@runBlocking` ### Set operations: account for hierarchical containment When computing set differences on file paths or module roots, check whether the domain has parent-child relationships. Plain set subtraction (`A - B`) flags children of requested items as "extra". - [ ] Every set difference on paths — does the code account for nesting? (e.g., `root !in expected && expected.none { root.startsWith("$it/") }`) --- ## API compliance — the plugin verifier enforces these; CI fails if violated ### Internal APIs - **NEVER** use `@ApiStatus.Internal` / `@Internal` classes or methods, even via reflection. `getDeclaredField` on an internal class still references it and is flagged. - Use the public builder API: `OpenProjectTask.build().withForceOpenInNewFrame(true)` not `getDeclaredField("forceOpenInNewFrame")`. - Cast-guarded access (`as? SomeInternalClass`) is still an internal reference. ### Deprecated APIs - Use `ModalityState.nonModal()` — **not** `ModalityState.NON_MODAL` (deprecated field) - Use `ModalityState.any()` — **not** `ModalityState.ANY` (deprecated field) ### Plugin availability detection - `PluginDetector` must use `PluginManagerCore.isLoaded` / `isDisabled` — **never** `PluginManager.findEnabledPlugin`, which was rejected in JetBrains Marketplace review. `scripts/check-pr.sh` fails if it reappears. ### Service registration - `@Service(Service.Level.APP)` on the class **and** `` in `plugin.xml` is a duplicate — use one or the other, not both. The annotation alone is sufficient for light services. - `@Storage` for services that persist machine-specific paths (absolute filesystem paths, project root paths) **must** include `roamingType = RoamingType.DISABLED`. Without it, Settings Sync will copy local paths to other machines. --- ## Threading — IntelliJ's threading model ### The two locks - **Read lock** — required to access PSI. Acquire with `ReadAction.compute { }` or `readAction { }`. Any background thread can acquire a read lock. - **Write lock** — required to modify PSI. Acquire with `WriteCommandAction.runWriteCommandAction`. Must run on the EDT. ### EDT rules | Operation | Thread | How | |-----------|--------|-----| | PSI read (search, navigate) | Any background thread | `ReadAction.compute { }` | | PSI write (rename, reformat) | EDT | `WriteCommandAction.runWriteCommandAction` | | UI updates, `DumbService.runWhenSmart` | EDT | `invokeLater` / `edtAction { }` | | `PowerSaveMode.setEnabled()` | EDT | `edtAction { }` | | `PowerSaveMode.isEnabled()` | Any | direct call | ### Modal dialog safety `ModalityState.nonModal()` is the **safe default** for PSI, VFS, project-model, and write-action work. The IntelliJ SDK modality system exists precisely to prevent these operations from running while a modal dialog is waiting for user input — using `ModalityState.any()` here risks corrupting state the dialog depends on. `ModalityState.any()` is appropriate **only** for pure UI/status notifications where running during a modal is intentional and no PSI/VFS/project-model mutation occurs. If `nonModal()` blocks a long-running MCP wait (e.g. `DumbService.runWhenSmart` hanging because a modal dialog is open), the correct fix is a **timeout**, not a modality switch: ```kotlin withTimeoutOrNull(120_000L) { suspendCancellableCoroutine { continuation -> ApplicationManager.getApplication().invokeLater({ DumbService.getInstance(project).runWhenSmart { continuation.resume(Unit) } }, ModalityState.nonModal()) } } ``` If you need a modal-tolerant EDT helper for a specific case, create a distinctly named function (e.g. `uiNotifyAction { }`) and document explicitly that it must not mutate PSI, VFS, or the project model. Do not change the shared `edtAction { }` helper. ### Concurrent collections Sets mutated from multiple threads (Ktor coroutines, Alarm callbacks, EDT) must use `ConcurrentHashMap.newKeySet()`, not `mutableSetOf()` (which is `LinkedHashSet` — not thread-safe). ### Alarm lifecycle `Alarm.cancelAllRequests()` stops pending callbacks but does **not** free the `Alarm` object. On permanent project release or close, call `Disposer.dispose(alarm)` and remove it from any map it lives in. Abandoned `Alarm` objects accumulate silently. --- ## Error messages — make them actionable Every error a tool returns should tell the caller exactly what to do next. | Situation | Required guidance in error message | |-----------|----------------------------------| | IDE in dumb mode (indexing) | DO NOT fall back to bash/grep; call `ide_index_status` until `isDumbMode` is false; retry the same call | | Outdated stub in index (PSI cache stale) | Call `ide_sync_files`; retry | | Build system not linked in IDE | Explain how to link it (Maven/Gradle tool window → Import) | | Project not open | What to do to open it | Never return a raw Java stack trace as the error message. Catch known exception types and convert them to user-facing messages. --- ## Memory — avoid accumulation - **History result strings**: tool responses stored in `CommandHistoryService` must be truncated before storage (current cap: 4 KB). `ide_find_references` on a popular class can return 100 KB+; at 100 entries × 10 projects this becomes significant. - **Alarm objects**: dispose AND remove from maps on permanent release — do not only cancel. - **PSI references**: do not store `PsiElement`, `PsiFile`, or `VirtualFile` references in long-lived services. They become invalid when projects close and prevent GC. --- ## Detecting linked build systems When a tool needs to know whether a project uses Maven or Gradle, check whether the build system is **actually linked in IntelliJ** — not just whether a build file exists on disk. ```kotlin // Correct: checks IntelliJ's project model val linked = ProjectDataManager.getInstance().getExternalProjectsData(project, systemId) val isLinked = linked.isNotEmpty() // Wrong: only checks filesystem val hasPom = File(project.basePath ?: "", "pom.xml").exists() ``` If a build file exists but the project is not linked, tell the user how to link it rather than claiming success. --- ## Known gaps in the test suite Stated plainly, so nobody mistakes green for covered. The suite is strong against "a refactor dropped a tool or mutated an input schema" and good against "a Java refactoring stopped updating call sites". It is thin in these areas: - **No mock JDK.** `testFramework(TestFrameworkType.Plugin.Java)` is declared, but no test supplies a `getProjectDescriptor()`, so fixtures run without an SDK and `java.lang.*` does not resolve. Consequence: `GetDiagnosticsToolBehaviorTest`'s `contains("Cannot resolve")` assertions cannot distinguish a deliberate error from the missing standard library. Wiring a descriptor with `JAVA_LATEST` is the fix. - **Dumb-mode coverage is minimal.** `ClassResolverTest` uses `DumbModeTestUtils` to pin that FQN lookup propagates `IndexNotReadyException` (which the tool layer turns into retry guidance) instead of misreporting "Class not found", but no test yet drives an index-backed tool end-to-end through dumb mode to prove it degrades gracefully at the MCP boundary. - **No whole-file golden fixtures.** `configureByFile` / `checkResultByFile` are unused, so formatting damage and collateral edits outside the asserted region are invisible. This matters most for `ide_reformat_code` and `ide_optimize_imports`. - **Reflection-based language handlers are largely unverified.** Python, Go, PHP and Rust handlers are reached only through reflection against plugins absent from the test classpath. The Python hierarchy and call-hierarchy handlers have no automated coverage at all — verify changes to them in the corresponding IDE by hand. - **No test exercises real Kotlin PSI.** The Kotlin plugin is not on the test classpath, so every Kotlin-specific code path — light-class handling in `JavaHandlers.kt`, `KtProperty` resolution in `JavaSymbolReferenceHandler`, the Kotlin branches of the refactoring tools — is covered only by unit tests over the surrounding helpers, or not at all. Verify Kotlin changes by hand in the IDE. Two routes have been tried and both are closed: - `testBundledPlugin("org.jetbrains.kotlin")` fails `compileTestKotlin`. The bundled plugin's jars carry newer Kotlin metadata than the generation this platform build compiles against — the same pin documented in `gradle/libs.versions.toml` and in `docs/mcp-kotlin-sdk-migration.md` §11.1. - Filtering those jars off the `compileTestKotlin` classpath does compile, and the plugin loads (`PluginDetectors.kotlin.isAvailable` is true, fixtures parse to real Kotlin PSI). But a real `ReferencesSearch` then dies inside the platform's `KotlinReferencesSearcher` with `NoSuchMethodError: SequencesKt.sequenceOf`, via `LightClassUtil.getWrappingClasses`. A real IDE loads bundled plugins through isolated per-plugin classloaders with matched dependencies; a Gradle test JVM flattens them onto one classpath. - **Some tools are still never executed by any test**, only schema- and response-shape-checked: `ide_build_project`, `ide_reload_project`, `ide_import_modules`, `ide_open_workspace`, `ide_restart`, `ide_lifecycle_log`, `ide_set_lifecycle_log_file`, and `ide_run_tests` (only its `parseTarget` helper is covered). `ide_reformat_code` has error paths only. Adding a behavior test for one is a genuinely useful first contribution — see the checklist above for the shape. - **`ide_symbol_info` is tested only on its `java_psi` tier.** The `quick_navigation` and `element_text` fallbacks in `SymbolSignatureResolver` have no automated coverage. A TypeScript fixture does reach `quick_navigation`, but adding one made a JS/TS background task throw on an application pooled thread during teardown — `BackendThreadPoolExecutor.afterExecute` logged it and `TestLoggerFactory$TestLogger.error` then failed inside `AsyncLog`. The build stayed green (the throw lands outside any test method), so it was a permanent unexplained error in the log rather than a failure; the test was removed instead of shipped. `element_text` needs an element no documentation provider answers for, which the fixture platform does not produce reliably. Verify both tiers by hand in the corresponding IDE. - **`SafeDeleteTool` cannot see references in files outside any content root.** The usage search scope only covers content roots, so a reference living outside them will not block deletion. (The former fail-open exception handling — treating a failed `ReferencesSearch` as "no usages" — was fixed in 5.0.1: a failed search now aborts the deletion unless `force=true`, and `SafeDeleteToolBehaviorTest` pins that behavior.) If you close one of these, delete its bullet in the same PR. ## Smoke test protocol After any `./gradlew buildPlugin` → install → restart cycle, run the smoke test at `smoke-tests/mcp-protocol.md` when changes touch: - HTTP transport (`server/transport/KtorMcpServer.kt`, `LegacySseRoutes.kt`, `LocalOriginGuard.kt`) - Tool registration (`ToolRegistry.kt`, `McpServerService.kt`) - Any tool covered by the protocol Skip for documentation-only, test-only, or version-bump changes. --- ## Project structure quick reference ``` src/main/kotlin/.../ ├── constants/ ToolNames.kt — all tool name constants + ALL list ├── history/ Per-project command history (bounded ring buffer) ├── lifecycle/ ProjectModeService, LifecycleEventLog, focus tracking ├── server/ McpServerService, ProjectResolver, PaginationService, │ mcp/ (McpServerFactory, McpToolDispatcher), │ transport/ (KtorMcpServer, LegacySseRoutes, LocalOriginGuard) ├── settings/ McpSettings (app-level persisted state) ├── tools/ │ ├── AbstractMcpTool.kt — extend this, implement doExecute() │ ├── ToolRegistry.kt — register new tools here │ ├── schema/SchemaBuilder.kt — always use for inputSchema │ ├── editor/ ide_get_active_file, ide_open_file │ ├── intelligence/ ide_diagnostics │ ├── lifecycle/ lifecycle management tools │ ├── navigation/ ide_find_*, ide_search_text, ide_*_hierarchy │ ├── project/ ide_index_status, ide_sync_files, ide_build_project │ └── refactoring/ ide_refactor_*, ide_reformat_code └── util/ Threading helpers, PSI utilities ``` --- ## Adding dependencies 1. Add version to `gradle/libs.versions.toml` 2. Reference in `build.gradle.kts` via `libs.` 3. If the dependency conflicts with IntelliJ's bundled coroutines or slf4j, add `exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-core")` (see existing Ktor entries for the pattern)