Skip to content

Commit 1dd1bb3

Browse files
committed
feat: introspect application/project/module services and message-bus listeners
Adds two new MCP tool groups (services.* and events.*) plus matching Platform Explorer tool-window sections for the IDE's declarative service and listener registrations: - services.list / services.find — XML-declared <applicationService>, <projectService>, <moduleService>, optionally also @Service-annotated light services already instantiated in the running IDE. - events.list_listeners / events.find_listeners_of_topic — static <applicationListeners> / <projectListeners> bindings. - arch.get_plugin_details now includes services and listeners by default. Two new core inspectors (ServiceInspector, ListenerInspector) read the data reflectively from IdeaPluginDescriptorImpl.{app,project,module}ContainerDescriptor so we don't compile-time depend on @ApiStatus.Internal types. Light services use ComponentManagerEx.processAllImplementationClasses across application and every open project. PluginInventory snapshot gains servicesByPluginId and listenersByPluginId maps with a separate shorter-TTL cache for the non-deterministic light-service walk. https://claude.ai/code/session_01AUSj3uiQmBbVmSRYEpYHYq
1 parent bc71cd5 commit 1dd1bb3

20 files changed

Lines changed: 1502 additions & 7 deletions

docs/MCP_TOOLS.md

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Generated from the `@McpTool` / `@McpDescription` annotations on the `McpToolset
66
classes by a KSP processor (`doc-processor/`) that runs as part of `compileKotlin`.
77
To refresh: any `./gradlew build` (or `./gradlew compileKotlin`) regenerates this file.
88

9-
**Total tools:** 21
9+
**Total tools:** 25
1010

1111
## Tools by group
1212

@@ -25,6 +25,11 @@ To refresh: any `./gradlew build` (or `./gradlew compileKotlin`) regenerates thi
2525
- [`code.get_source`](#code-getsource)
2626
- [`code.list_members`](#code-listmembers)
2727

28+
### `events.*` (2)
29+
30+
- [`events.find_listeners_of_topic`](#events-findlistenersoftopic)
31+
- [`events.list_listeners`](#events-listlisteners)
32+
2833
### `exec.*` (1)
2934

3035
- [`exec.execute_kotlin_in_ide`](#exec-executekotlininide)
@@ -41,6 +46,11 @@ To refresh: any `./gradlew build` (or `./gradlew compileKotlin`) regenerates thi
4146
- [`screenshot.capture`](#screenshot-capture)
4247
- [`screenshot.crop`](#screenshot-crop)
4348

49+
### `services.*` (2)
50+
51+
- [`services.find`](#services-find)
52+
- [`services.list`](#services-list)
53+
4454
### `ui.*` (5)
4555

4656
- [`ui.find_by_coordinates`](#ui-findbycoordinates)
@@ -124,6 +134,8 @@ Examples:
124134
| `pluginId` | `String` | Plugin id, e.g. 'com.intellij.java', 'org.jetbrains.kotlin', 'com.github.xepozz.ide.introspector'. Get ids from arch.list_plugins. |
125135
| `includeDeclaredExtensionPoints` | `Boolean` | Include EPs this plugin declares (i.e. extensibility hooks it offers to others). Default true. |
126136
| `includeRegisteredExtensions` | `Boolean` | Include extensions this plugin contributes to other plugins' EPs. Default true. |
137+
| `includeServices` | `Boolean` | Include services declared by this plugin (application/project/module). Default true — cheap. |
138+
| `includeListeners` | `Boolean` | Include message-bus listeners declared by this plugin (application/project). Default true — cheap. |
127139
| `includeActions` | `Boolean` | Include the plugin's action ids. Default false — slow on plugins with many actions (com.intellij has ~3000). |
128140

129141
**Returns:** `PluginDetails`
@@ -407,6 +419,76 @@ Examples:
407419

408420
---
409421

422+
## `events.find_listeners_of_topic`
423+
424+
*EventsToolset*
425+
426+
Reverse-lookup: given a Topic FQCN, lists every static listener registered against it.
427+
428+
Use this when: you have a specific topic class (e.g. `com.intellij.openapi.vfs.newvfs.BulkFileListener`)
429+
and want to know what reacts to its events.
430+
431+
Do NOT use this when: you have a substring (use events.list_listeners with topicContains).
432+
433+
Returns: { listeners: ListenerInfo[], total: int }.
434+
435+
Examples:
436+
topicClass="com.intellij.openapi.vfs.newvfs.BulkFileListener"
437+
topicClass="com.intellij.openapi.fileEditor.FileEditorManagerListener"
438+
439+
**Parameters**
440+
441+
| Name | Type | Description |
442+
| --- | --- | --- |
443+
| `topicClass` | `String` | Fully-qualified Topic class name. Use the topicClass values from events.list_listeners. |
444+
445+
**Returns:** `ListListenersResponse`
446+
447+
---
448+
449+
## `events.list_listeners`
450+
451+
*EventsToolset*
452+
453+
Lists IntelliJ message-bus listeners declared statically in plugin.xml via
454+
`<applicationListeners>` and `<projectListeners>`. These are pairs of (topic class,
455+
listener class) wired up by the platform on application/project initialization —
456+
IntelliJ's idiomatic, declarative event-subscription mechanism.
457+
458+
Use this when: you want to find "who reacts to file edits / VCS state / project open?",
459+
"which listeners does plugin X register?", "what are the application-level listeners
460+
that fire on startup?". Common starting point for understanding plugin event flow.
461+
462+
Do NOT use this when: you want to know who subscribed *at runtime* via
463+
`messageBus.connect().subscribe(...)` (out of scope — those subscriptions are
464+
imperative, not enumerable), or you want the available topic classes themselves (look
465+
at the topicClass field on the returned listeners, or grep platform sources for
466+
`Topic<...>`).
467+
468+
Returns: { listeners: ListenerInfo[], total: int } where each ListenerInfo has
469+
topicClass (FQCN of the Topic), listenerClass (FQCN of the implementation),
470+
area ('application'|'project'), activeInTestMode, activeInHeadlessMode, os (when
471+
restricted), providedByPluginId/Name.
472+
473+
Examples:
474+
topicContains="FileEditorManager" — every listener for FileEditorManager.Listener topics
475+
area="project", providedByPlugin="com.github.xepozz.ide.introspector" — this plugin's project listeners
476+
listenerContains="StartupActivity" — listeners whose impl mentions StartupActivity
477+
478+
**Parameters**
479+
480+
| Name | Type | Description |
481+
| --- | --- | --- |
482+
| `area` | `String` | 'application', 'project', or 'all'. Default 'all'. |
483+
| `providedByPlugin` | `String?` | Restrict to listeners contributed by this plugin id. |
484+
| `topicContains` | `String?` | Case-insensitive substring filter on the topic FQCN. |
485+
| `listenerContains` | `String?` | Case-insensitive substring filter on the listener implementation FQCN. |
486+
| `limit` | `Int` | Cap on returned listeners. Default 500. |
487+
488+
**Returns:** `ListListenersResponse`
489+
490+
---
491+
410492
## `exec.execute_kotlin_in_ide`
411493

412494
*ExecToolset*
@@ -794,6 +876,83 @@ Examples:
794876

795877
---
796878

879+
## `services.find`
880+
881+
*ServicesToolset*
882+
883+
Reverse-lookup for services: given an interface or implementation FQCN, returns every
884+
ServiceInfo where it matches. Use targetKind="auto" (default) to match either field.
885+
886+
Use this when: you have a specific class in mind and want to know "is this registered
887+
as a service?", "who provides the implementation of X?", "is interface Y backed by
888+
multiple impls (via overrides=true)?".
889+
890+
Do NOT use this when: you only have a substring (use services.list with
891+
interfaceContains/implementationContains).
892+
893+
Returns: { services: ServiceInfo[], total: int }.
894+
895+
Examples:
896+
target="com.intellij.openapi.project.ProjectManager" — every service registered against this interface
897+
target="com.intellij.openapi.project.impl.ProjectManagerImpl" — service(s) using this implementation
898+
899+
**Parameters**
900+
901+
| Name | Type | Description |
902+
| --- | --- | --- |
903+
| `target` | `String` | Fully-qualified class name to search for. Matches interfaceClass and/or implementationClass exactly. |
904+
| `targetKind` | `String` | 'interface' (match interfaceClass), 'implementation' (match implementationClass), or 'auto' (default — match either). |
905+
906+
**Returns:** `ListServicesResponse`
907+
908+
---
909+
910+
## `services.list`
911+
912+
*ServicesToolset*
913+
914+
Lists IntelliJ services declared in plugin.xml via `<applicationService>` /
915+
`<projectService>` / `<moduleService>` across every installed plugin. Optionally also
916+
enumerates `@Service`-annotated light services already instantiated in the running
917+
IDE session (best-effort, non-deterministic — depends on what's been touched).
918+
919+
Use this when: you want a typed view of plugin services (interface vs implementation,
920+
preload mode, client/os restrictions, area) — much richer than going through
921+
arch.list_extensions_for_ep("com.intellij.applicationService"). Common questions:
922+
"what app-level services does plugin X register?", "what services have preload=TRUE?",
923+
"is there a service for interface Y?".
924+
925+
Do NOT use this when: you need ALL extensions of any kind (use arch.list_extensions_for_ep
926+
with a specific EP name), or you want to inspect *components* (deprecated platform
927+
construct, not exposed by this tool).
928+
929+
Returns: { services: ServiceInfo[], total: int } where each ServiceInfo has
930+
interfaceClass (optional — null when impl is the service interface), implementationClass
931+
(canonical FQCN), testServiceImplementation/headlessImplementation (when overridden),
932+
area ('application'|'project'|'module'), preload ('FALSE'|'TRUE'|'AWAIT'|'NOT_HEADLESS'|
933+
'NOT_LIGHT_EDIT'), client (ClientKind name when set), os, overrides,
934+
configurationSchemaKey, providedByPluginId/Name, source ('xml'|'light_instantiated').
935+
936+
Examples:
937+
area="application", providedByPlugin="org.jetbrains.kotlin" — Kotlin's app services
938+
implementationContains="ProjectManager" — any service implementing/extending ProjectManager
939+
includeLightInstantiated=true — also list @Service classes already loaded
940+
941+
**Parameters**
942+
943+
| Name | Type | Description |
944+
| --- | --- | --- |
945+
| `area` | `String` | 'application' (most common), 'project', 'module', or 'all'. Default 'all'. |
946+
| `providedByPlugin` | `String?` | Restrict to services contributed by this plugin id (e.g. 'com.intellij', 'org.jetbrains.kotlin'). |
947+
| `interfaceContains` | `String?` | Case-insensitive substring filter on the declared interface FQCN. |
948+
| `implementationContains` | `String?` | Case-insensitive substring filter on the implementation FQCN. |
949+
| `includeLightInstantiated` | `Boolean` | Also include @Service-annotated light services already instantiated in this IDE session. Default false — result is non-deterministic. |
950+
| `limit` | `Int` | Cap on returned services. Default 500. |
951+
952+
**Returns:** `ListServicesResponse`
953+
954+
---
955+
797956
## `ui.find_by_coordinates`
798957

799958
*UiInspectorToolset*
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.github.xepozz.ide.introspector.core
2+
3+
import com.github.xepozz.ide.introspector.model.ListenerInfo
4+
import com.intellij.ide.plugins.IdeaPluginDescriptor
5+
import com.intellij.ide.plugins.PluginManagerCore
6+
import com.intellij.openapi.diagnostic.thisLogger
7+
8+
/**
9+
* Reads `<applicationListeners>` and `<projectListeners>` declarations off the live
10+
* [com.intellij.ide.plugins.IdeaPluginDescriptorImpl.appContainerDescriptor] /
11+
* `projectContainerDescriptor`. Uses reflection because both [com.intellij.util.messages.ListenerDescriptor]
12+
* and the container class are `@ApiStatus.Internal`.
13+
*
14+
* Runtime `messageBus.connect().subscribe(...)` subscribers are out of scope — they're attached
15+
* imperatively, not declaratively, and the platform doesn't expose a deterministic enumeration.
16+
*/
17+
object ListenerInspector {
18+
19+
fun listAll(): List<ListenerInfo> {
20+
val out = mutableListOf<ListenerInfo>()
21+
for (descriptor in PluginManagerCore.plugins) {
22+
collectFor(descriptor, out)
23+
}
24+
return out
25+
}
26+
27+
fun listForPlugin(descriptor: IdeaPluginDescriptor): List<ListenerInfo> {
28+
val out = mutableListOf<ListenerInfo>()
29+
collectFor(descriptor, out)
30+
return out
31+
}
32+
33+
private fun collectFor(descriptor: IdeaPluginDescriptor, out: MutableList<ListenerInfo>) {
34+
val pluginId = descriptor.pluginId.idString
35+
val pluginName = descriptor.name
36+
for ((areaTag, getter) in AREA_GETTERS) {
37+
val container = readContainer(descriptor, getter) ?: continue
38+
val listeners = readListenerList(container)
39+
for (ld in listeners) {
40+
out += try {
41+
toListenerInfo(ld, areaTag, pluginId, pluginName)
42+
} catch (t: Throwable) {
43+
thisLogger().debug("Failed to read ListenerDescriptor for $pluginId/$areaTag", t)
44+
null
45+
} ?: continue
46+
}
47+
}
48+
}
49+
50+
internal fun toListenerInfo(
51+
ld: Any,
52+
areaTag: String,
53+
pluginId: String,
54+
pluginName: String?,
55+
): ListenerInfo? {
56+
val listenerClass = readField(ld, "listenerClassName")?.toString() ?: return null
57+
val topicClass = readField(ld, "topicClassName")?.toString() ?: return null
58+
val activeInTest = (readField(ld, "activeInTestMode") as? Boolean) ?: true
59+
val activeInHeadless = (readField(ld, "activeInHeadlessMode") as? Boolean) ?: true
60+
val os = readField(ld, "os")?.let { runCatching { (it as Enum<*>).name }.getOrNull() }
61+
return ListenerInfo(
62+
topicClass = topicClass,
63+
listenerClass = listenerClass,
64+
area = areaTag,
65+
activeInTestMode = activeInTest,
66+
activeInHeadlessMode = activeInHeadless,
67+
os = os,
68+
providedByPluginId = pluginId,
69+
providedByPluginName = pluginName,
70+
)
71+
}
72+
73+
private fun readContainer(descriptor: IdeaPluginDescriptor, getterName: String): Any? {
74+
val m = descriptor.javaClass.methods.firstOrNull {
75+
it.name == getterName && it.parameterCount == 0
76+
} ?: return null
77+
return try { m.invoke(descriptor) } catch (_: Throwable) { null }
78+
}
79+
80+
private fun readListenerList(container: Any): List<Any> {
81+
// ContainerDescriptor.listeners is a @JvmField — public field on the JVM.
82+
val field = container.javaClass.fields.firstOrNull { it.name == "listeners" }
83+
?: return emptyList()
84+
val raw = try { field.get(container) } catch (_: Throwable) { return emptyList() }
85+
return (raw as? List<*>)?.filterNotNull().orEmpty()
86+
}
87+
88+
private fun readField(target: Any, name: String): Any? {
89+
var c: Class<*>? = target.javaClass
90+
while (c != null) {
91+
val f = c.declaredFields.firstOrNull { it.name == name }
92+
if (f != null) {
93+
return try {
94+
f.isAccessible = true
95+
f.get(target)
96+
} catch (_: Throwable) { null }
97+
}
98+
c = c.superclass
99+
}
100+
return null
101+
}
102+
103+
private val AREA_GETTERS = listOf(
104+
"application" to "getAppContainerDescriptor",
105+
"project" to "getProjectContainerDescriptor",
106+
)
107+
}

0 commit comments

Comments
 (0)