[AIA/MCP Server] Add reliable API to report user-friendly tool activity description

GitOrigin-RevId: 4b95ea4d179c1b44dbefb1c5eae1037684e53573
This commit is contained in:
Artem.Bukhonov
2025-07-12 18:05:23 +02:00
committed by intellij-monorepo-bot
parent 2c814de7d8
commit 069e66b706
12 changed files with 77 additions and 19 deletions

View File

@@ -0,0 +1,12 @@
package com.intellij.mcpserver
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
data class McpToolDescriptorElement(val mcpToolDescriptor: McpToolDescriptor?) : AbstractCoroutineContextElement(Key) {
companion object Key : CoroutineContext.Key<McpToolDescriptorElement>
}
val CoroutineContext.currentToolDescriptorOrNull: McpToolDescriptor? get() = get(McpToolDescriptorElement)?.mcpToolDescriptor
val CoroutineContext.currentToolDescriptor: McpToolDescriptor get() = get(McpToolDescriptorElement)?.mcpToolDescriptor
?: error("currentToolDescriptor called outside of a MCP tool call")

View File

@@ -3,6 +3,7 @@ package com.intellij.mcpserver
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.application
import com.intellij.util.messages.Topic
import kotlin.coroutines.CoroutineContext
interface ToolCallListener {
companion object {
@@ -12,9 +13,9 @@ interface ToolCallListener {
fun beforeMcpToolCall(mcpToolDescriptor: McpToolDescriptor) {}
fun afterMcpToolCall(mcpToolDescriptor: McpToolDescriptor, events: List<McpToolSideEffectEvent>) {}
fun afterMcpToolCall(mcpToolDescriptor: McpToolDescriptor, events: List<McpToolSideEffectEvent>, error: Throwable?) {}
fun toolActivity(toolDescription: String) {}
fun toolActivity(mcpToolDescriptor: McpToolDescriptor, toolActivityDescription: String) {}
}
sealed interface McpToolSideEffectEvent
@@ -26,6 +27,6 @@ class FileDeletedEvent(val file: VirtualFile, val content: String?) : FileEvent
class FileMovedEvent(val file: VirtualFile, val oldParent: VirtualFile, val newParent: VirtualFile) : FileEvent
class FileContentChangeEvent(val file: VirtualFile, val oldContent: String?, val newContent: String) : FileEvent
fun reportToolActivity(toolDescription: String) {
application.messageBus.syncPublisher(ToolCallListener.TOPIC).toolActivity(toolDescription)
fun CoroutineContext.reportToolActivity(toolDescription: String) {
application.messageBus.syncPublisher(ToolCallListener.TOPIC).toolActivity(this.currentToolDescriptor, toolDescription)
}

View File

@@ -217,47 +217,47 @@ private fun McpTool.mcpToolToRegisteredTool(): RegisteredTool {
EditorFactory.getInstance().eventMulticaster.addDocumentListener(documentListener, this.asDisposable())
val sideEffectEvents = mutableListOf<McpToolSideEffectEvent>()
@Suppress("IncorrectCancellationExceptionHandling")
try {
application.messageBus.syncPublisher(ToolCallListener.TOPIC).beforeMcpToolCall(this@mcpToolToRegisteredTool.descriptor)
logger.trace { "Start calling tool '${this@mcpToolToRegisteredTool.descriptor.name}'. Arguments: ${request.arguments}" }
val result = withContext(ProjectContextElement(project)) {
val result = withContext(ProjectContextElement(project) + McpToolDescriptorElement(this@mcpToolToRegisteredTool.descriptor)) {
this@mcpToolToRegisteredTool.call(request.arguments)
}
logger.trace { "Tool call successful '${this@mcpToolToRegisteredTool.descriptor.name}'. Result: ${result.content.joinToString("\n") { it.toString() }}" }
try {
val processedChangedFiles = mutableSetOf<VirtualFile>()
val events = mutableListOf<McpToolSideEffectEvent>()
for ((doc, oldContent) in initialDocumentContents) {
val virtualFile = FileDocumentManager.getInstance().getFile(doc) ?: continue
val newContent = readAction { doc.text }
events.add(FileContentChangeEvent(virtualFile, oldContent, newContent))
sideEffectEvents.add(FileContentChangeEvent(virtualFile, oldContent, newContent))
processedChangedFiles.add(virtualFile)
}
for (event in vfsEvent) {
when (event) {
is VFileMoveEvent -> {
events.add(FileMovedEvent(event.file, event.oldParent, event.newParent))
sideEffectEvents.add(FileMovedEvent(event.file, event.oldParent, event.newParent))
}
is VFileCreateEvent -> {
val virtualFile = event.file ?: continue
val newContent = readAction { FileDocumentManager.getInstance().getDocument(virtualFile)?.text } ?: continue
events.add(FileCreatedEvent(virtualFile, newContent))
sideEffectEvents.add(FileCreatedEvent(virtualFile, newContent))
}
is VFileDeleteEvent -> {
val virtualFile = event.file
val document = readAction { FileDocumentManager.getInstance().getDocument(virtualFile) } ?: continue
val oldContent = initialDocumentContents[document]
events.add(FileDeletedEvent(virtualFile, oldContent))
sideEffectEvents.add(FileDeletedEvent(virtualFile, oldContent))
}
is VFileCopyEvent -> {
val createdFile = event.findCreatedFile() ?: continue
val newContent = readAction { FileDocumentManager.getInstance().getDocument(createdFile)?.text } ?: continue
events.add(FileCreatedEvent(createdFile, newContent))
sideEffectEvents.add(FileCreatedEvent(createdFile, newContent))
}
is VFileContentChangeEvent -> {
// reported in documents loop
@@ -267,12 +267,11 @@ private fun McpTool.mcpToolToRegisteredTool(): RegisteredTool {
// Important: there may be a case when file is changed via low level change (like File.replaceText).
// in this case we don't track the old content, because it may be heavy, it requires loading the file in
// AsyncFileListener above and decoding with encoding etc. The file can be binary etc.
events.add(FileContentChangeEvent(virtualFile, oldContent = null, newContent = newContent))
sideEffectEvents.add(FileContentChangeEvent(virtualFile, oldContent = null, newContent = newContent))
}
}
}
application.messageBus.syncPublisher(ToolCallListener.TOPIC).afterMcpToolCall(this@mcpToolToRegisteredTool.descriptor, events)
}
catch (ce: CancellationException) {
throw ce
@@ -280,20 +279,24 @@ private fun McpTool.mcpToolToRegisteredTool(): RegisteredTool {
catch (t: Throwable) {
logger.error("Failed to process changed documents after calling MCP tool ${this@mcpToolToRegisteredTool.descriptor.name}", t)
}
application.messageBus.syncPublisher(ToolCallListener.TOPIC).afterMcpToolCall(this@mcpToolToRegisteredTool.descriptor, sideEffectEvents, null)
result
}
catch (ce: CancellationException) {
val message = "MCP tool call has been cancelled: ${ce.message}"
logger.traceThrowable { CancellationException(message, ce) }
application.messageBus.syncPublisher(ToolCallListener.TOPIC).afterMcpToolCall(this@mcpToolToRegisteredTool.descriptor, sideEffectEvents, ce)
McpToolCallResult.error(message)
}
catch (mcpException: McpExpectedError) {
logger.traceThrowable { mcpException }
application.messageBus.syncPublisher(ToolCallListener.TOPIC).afterMcpToolCall(this@mcpToolToRegisteredTool.descriptor, sideEffectEvents, mcpException)
McpToolCallResult.error(mcpException.mcpErrorText)
}
catch (t: Throwable) {
val errorMessage = "MCP tool call has been failed: ${t.message}"
logger.error(t)
application.messageBus.syncPublisher(ToolCallListener.TOPIC).afterMcpToolCall(this@mcpToolToRegisteredTool.descriptor, sideEffectEvents, t)
McpToolCallResult.error(errorMessage)
}
finally {

View File

@@ -51,7 +51,7 @@ class AnalysisToolset : McpToolset {
@McpDescription(Constants.TIMEOUT_MILLISECONDS_DESCRIPTION)
timeout: Int = Constants.MEDIUM_TIMEOUT_MILLISECONDS_VALUE,
): FileProblemsResult {
reportToolActivity("Collecting problems in file $filePath")
currentCoroutineContext().reportToolActivity("Collecting problems in file $filePath")
val project = currentCoroutineContext().project
val projectDir = project.projectDirectory
@@ -107,6 +107,7 @@ class AnalysisToolset : McpToolset {
@McpDescription(Constants.TIMEOUT_MILLISECONDS_DESCRIPTION)
timeout: Int = Constants.LONG_TIMEOUT_MILLISECONDS_VALUE,
): ProjectProblemsResult {
currentCoroutineContext().reportToolActivity("Checking project issues")
val project = currentCoroutineContext().project
val problems = CopyOnWriteArrayList<ProjectProblem>()
@@ -144,6 +145,7 @@ class AnalysisToolset : McpToolset {
|Returns structured information about each module including name and type.
""")
suspend fun get_project_modules(): ProjectModulesResult {
currentCoroutineContext().reportToolActivity("Listing modules")
val project = currentCoroutineContext().project
val modules = readAction {
@@ -165,6 +167,7 @@ class AnalysisToolset : McpToolset {
|Returns structured information about project library names.
""")
suspend fun get_project_dependencies(): ProjectDependenciesResult {
currentCoroutineContext().reportToolActivity("Checking dependencies")
val project = currentCoroutineContext().project
val dependencies = readAction {

View File

@@ -6,6 +6,7 @@ import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.mcpFail
import com.intellij.mcpserver.project
import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.toolsets.Constants
import com.intellij.mcpserver.util.SymbolInfo
import com.intellij.mcpserver.util.convertHtmlToMarkdown
@@ -45,6 +46,7 @@ class CodeInsightToolset : McpToolset {
@McpDescription("1-based column number")
column: Int,
): SymbolInfoResult {
currentCoroutineContext().reportToolActivity("Getting symbol info at '$filePath:$line:$column'")
val project = currentCoroutineContext().project
val resolvedPath = project.resolveInProject(filePath)

View File

@@ -18,6 +18,7 @@ import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.mcpFail
import com.intellij.mcpserver.project
import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.toolsets.Constants
import com.intellij.mcpserver.util.TruncateMode
import com.intellij.mcpserver.util.checkUserConfirmationIfNeeded
@@ -43,6 +44,7 @@ class ExecutionToolset : McpToolset {
|Use this tool to query the list of available run configurations in the current project.
""")
suspend fun get_run_configurations(): RunConfigurationsList {
currentCoroutineContext().reportToolActivity("Getting run configurations")
val project = currentCoroutineContext().project
val runManager = RunManager.getInstance(project)
@@ -78,6 +80,7 @@ class ExecutionToolset : McpToolset {
@McpDescription(Constants.TRUNCATE_MODE_DESCRIPTION)
truncateMode: TruncateMode = Constants.TRUCATE_MODE_VALUE,
): RunConfigurationResult {
currentCoroutineContext().reportToolActivity("Executing run configuration '$configurationName'")
val project = currentCoroutineContext().project
val runManager = RunManager.getInstance(project)

View File

@@ -3,8 +3,7 @@
package com.intellij.mcpserver.toolsets.general
import com.intellij.mcpserver.McpServerBundle
import com.intellij.mcpserver.McpToolset
import com.intellij.mcpserver.*
import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.mcpFail
@@ -31,6 +30,7 @@ import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import kotlinx.coroutines.*
import kotlinx.io.IOException
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
@@ -56,6 +56,7 @@ class FileToolset : McpToolset {
@McpDescription("Maximum recursion depth") maxDepth: Int = 5,
@McpDescription(Constants.TIMEOUT_MILLISECONDS_DESCRIPTION) timeout: Int = Constants.MEDIUM_TIMEOUT_MILLISECONDS_VALUE,
): DirectoryTreeInfo {
currentCoroutineContext().reportToolActivity("Traversing folder tree for '$directoryPath'")
val project = currentCoroutineContext().project
val resolvedPath = project.resolveInProject(directoryPath)
if (!resolvedPath.exists()) mcpFail("No such directory: $resolvedPath")
@@ -93,6 +94,7 @@ class FileToolset : McpToolset {
@McpDescription("Timeout in milliseconds")
timeout: Int = Constants.MEDIUM_TIMEOUT_MILLISECONDS_VALUE,
): FilesListResult {
currentCoroutineContext().reportToolActivity("Finding files with name containing '$nameKeyword'")
val project = currentCoroutineContext().project
val projectDir = project.projectDirectory
@@ -143,6 +145,7 @@ class FileToolset : McpToolset {
@McpDescription(Constants.TIMEOUT_MILLISECONDS_DESCRIPTION)
timeout: Int = Constants.MEDIUM_TIMEOUT_MILLISECONDS_VALUE
) : FilesListResult {
currentCoroutineContext().reportToolActivity("Finding files by glob '$globPattern'")
val project = currentCoroutineContext().project
val projectDirPath = project.projectDirectory
val fileIndex = ProjectRootManager.getInstance(project).getFileIndex()
@@ -202,6 +205,7 @@ class FileToolset : McpToolset {
@McpDescription(Constants.RELATIVE_PATH_IN_PROJECT_DESCRIPTION)
filePath: String,
) {
currentCoroutineContext().reportToolActivity("Opening file '$filePath'")
val project = currentCoroutineContext().project
val resolvedPath = project.resolveInProject(filePath)
@@ -222,6 +226,7 @@ class FileToolset : McpToolset {
|Use this tool to explore current open editors.
""")
suspend fun get_all_open_file_paths(): OpenFilesInfo {
currentCoroutineContext().reportToolActivity("Getting open files")
val project = currentCoroutineContext().project
val projectDir = project.projectDirectory
@@ -250,10 +255,21 @@ class FileToolset : McpToolset {
@McpDescription("Content to write into the new file")
text: String? = null,
) {
currentCoroutineContext().reportToolActivity("Creating file '$pathInProject'")
val project = currentCoroutineContext().project
val path = project.resolveInProject(pathInProject)
val newFile = LocalFileSystem.getInstance().createChildFile(null, VfsUtil.createDirectories(path.parent.pathString), path.name)
val newFile = try {
LocalFileSystem.getInstance().createChildFile(null, VfsUtil.createDirectories(path.parent.pathString), path.name)
}
catch (io: IOException) {
mcpFail("Can't create file: $path: ${io.message}")
}
val refreshed = CompletableDeferred<Unit>()
LocalFileSystem.getInstance().refreshFiles(listOf(newFile), true, false) {
refreshed.complete(Unit)
}
refreshed.await()
writeAction {
val document = FileDocumentManager.getInstance().getDocument(newFile, project) ?: mcpFail("Can't get document for created file: $newFile")
if (text != null) {

View File

@@ -8,6 +8,7 @@ import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.mcpFail
import com.intellij.mcpserver.project
import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.toolsets.Constants
import com.intellij.mcpserver.util.resolveInProject
import com.intellij.openapi.application.EDT
@@ -29,6 +30,7 @@ class FormattingToolset : McpToolset {
@McpDescription(Constants.RELATIVE_PATH_IN_PROJECT_DESCRIPTION)
path: String,
): String {
currentCoroutineContext().reportToolActivity("Formatting file '$path'")
val project = currentCoroutineContext().project
val resolvedFilePath = project.resolveInProject(path)

View File

@@ -5,6 +5,7 @@ import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.mcpFail
import com.intellij.mcpserver.project
import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.toolsets.Constants
import com.intellij.mcpserver.util.resolveInProject
import com.intellij.openapi.application.EDT
@@ -44,6 +45,7 @@ class RefactoringToolset : McpToolset {
@McpDescription("New name for the symbol")
newName: String,
): String {
currentCoroutineContext().reportToolActivity("Renaming '$symbolName' to '$newName' in '$pathInProject'")
val project = currentCoroutineContext().project
val resolvedPath = project.resolveInProject(pathInProject)

View File

@@ -10,6 +10,7 @@ import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.mcpFail
import com.intellij.mcpserver.project
import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.toolsets.Constants
import com.intellij.mcpserver.util.*
import com.intellij.openapi.application.readAction
@@ -51,6 +52,7 @@ class TextToolset : McpToolset {
@McpDescription("Max number of lines to return. Truncation will be performed depending on truncateMode.")
maxLinesCount: Int = 1000,
): String {
currentCoroutineContext().reportToolActivity("Reading file '$pathInProject'")
val project = currentCoroutineContext().project
val resolvedPath = project.resolveInProject(pathInProject)
@@ -102,6 +104,7 @@ class TextToolset : McpToolset {
@McpDescription("Case-sensitive search")
caseSensitive: Boolean = true,
) {
currentCoroutineContext().reportToolActivity("Replacing text in '$pathInProject': '$oldText' → '$newText'")
val project = currentCoroutineContext().project
val resolvedPath = project.resolveInProject(pathInProject)
val (document, text) = readAction {
@@ -153,7 +156,10 @@ class TextToolset : McpToolset {
maxUsageCount: Int = 1000,
@McpDescription(Constants.TIMEOUT_MILLISECONDS_DESCRIPTION)
timeout: Int = Constants.MEDIUM_TIMEOUT_MILLISECONDS_VALUE,
): UsageInfoResult = search_in_files(searchText, false, directoryToSearch, fileMask, caseSensitive, maxUsageCount, timeout)
): UsageInfoResult {
currentCoroutineContext().reportToolActivity("Searching project files for '$searchText'")
return search_in_files(searchText, false, directoryToSearch, fileMask, caseSensitive, maxUsageCount, timeout)
}
@McpTool
@McpDescription("""
@@ -175,7 +181,10 @@ class TextToolset : McpToolset {
maxUsageCount: Int = 1000,
@McpDescription(Constants.TIMEOUT_MILLISECONDS_DESCRIPTION)
timeout: Int = Constants.MEDIUM_TIMEOUT_MILLISECONDS_VALUE,
): UsageInfoResult = search_in_files(regexPattern, true, directoryToSearch, fileMask, caseSensitive, maxUsageCount, timeout)
): UsageInfoResult {
currentCoroutineContext().reportToolActivity("Searching content with regex '$regexPattern'")
return search_in_files(regexPattern, true, directoryToSearch, fileMask, caseSensitive, maxUsageCount, timeout)
}
private suspend fun search_in_files(
searchTextOrRegex: String,

View File

@@ -7,6 +7,7 @@ import com.intellij.mcpserver.McpToolset
import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.project
import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.toolsets.Constants
import com.intellij.mcpserver.util.TruncateMode
import com.intellij.mcpserver.util.checkUserConfirmationIfNeeded
@@ -53,6 +54,7 @@ class TerminalToolset : McpToolset {
@McpDescription(Constants.TRUNCATE_MODE_DESCRIPTION)
truncateMode: TruncateMode = Constants.TRUCATE_MODE_VALUE,
): CommandExecutionResult {
currentCoroutineContext().reportToolActivity("Running command: '$command'")
val project = currentCoroutineContext().project
checkUserConfirmationIfNeeded(McpServerBundle.message("label.do.you.want.to.execute.command.in.terminal"), command, project)

View File

@@ -6,6 +6,7 @@ import com.intellij.mcpserver.McpToolset
import com.intellij.mcpserver.annotations.McpDescription
import com.intellij.mcpserver.annotations.McpTool
import com.intellij.mcpserver.project
import com.intellij.mcpserver.reportToolActivity
import com.intellij.mcpserver.util.projectDirectory
import com.intellij.openapi.vcs.ProjectLevelVcsManager
import com.intellij.openapi.vcs.changes.ChangeListManager
@@ -26,6 +27,7 @@ class VcsToolset : McpToolset {
@McpDescription("Text or keywords to search for in commit messages")
text: String
): String {
currentCoroutineContext().reportToolActivity("Searching commits for '$text'")
val project = currentCoroutineContext().project
val queryText = text
val matchingCommits = mutableListOf<String>()
@@ -77,6 +79,7 @@ class VcsToolset : McpToolset {
Note: Works with any VCS supported by the IDE, but is most commonly used with Git
""")
suspend fun get_project_vcs_status(): String {
currentCoroutineContext().reportToolActivity("Checking VCS status")
val project = currentCoroutineContext().project
val projectDir = project.projectDirectory