LLM-22191 [ACP] provide a list of available idea mcp tools in acp.json merged

GitOrigin-RevId: 53f36b6c287860a78d36bec22235d7b9e6197b84
This commit is contained in:
Anna.Zhdan
2026-01-15 19:03:41 +00:00
committed by intellij-monorepo-bot
parent fc0ff807b9
commit 9152bc3bfa
5 changed files with 383 additions and 21 deletions
@@ -14,9 +14,16 @@ import kotlinx.io.buffered
private const val IJ_MCP_PREFIX = "IJ_MCP_"
const val IJ_MCP_HEADER_PREFIX: String = "${IJ_MCP_PREFIX}HEADER_"
val IJ_MCP_SERVER_PORT: String = ::IJ_MCP_SERVER_PORT.name
val IJ_MCP_ALLOWED_TOOLS: String = ::IJ_MCP_ALLOWED_TOOLS.name
val IJ_MCP_SERVER_PROJECT_PATH: String = ::IJ_MCP_SERVER_PROJECT_PATH.name
private val IJ_MCP_DEBUG: String = ::IJ_MCP_DEBUG.name
/**
* Environment variables for tool filtering:
* - IJ_MCP_ALLOWED_TOOLS: Comma-separated list of allowed tool names
* Example: IJ_MCP_ALLOWED_TOOLS="read_file,write_file,grep,list_files"
*/
suspend fun main() {
val inputStream = System.`in`
val outputStream = System.out
@@ -0,0 +1,40 @@
package com.intellij.mcpserver
/**
* Filter for selecting which MCP tools should be exposed in a server session.
*
* Different filter implementations allow for flexible tool selection strategies.
* Filters are applied when registering tools for an MCP server session, allowing
* different agents to see different subsets of available tools.
*/
sealed interface McpToolFilter {
/**
* Checks if a tool with the given name should be included in the session.
*
* @param toolName the name of the tool to check
* @return true if the tool should be included, false otherwise
*/
fun shouldInclude(toolName: String): Boolean
/**
* Allow-all filter: all tools are included.
*
* This is the default filter when no restrictions are needed.
* Use this instead of null to explicitly indicate no filtering.
*/
data object AllowAll : McpToolFilter {
override fun shouldInclude(toolName: String): Boolean = true
}
/**
* Allow-list filter: only tools with names in the allowed set are included.
*
* This is useful for exposing a specific subset of tools to an agent,
* for example, limiting an agent to only file operations.
*
* @property allowedTools set of tool names that should be included
*/
data class AllowList(val allowedTools: Set<String>) : McpToolFilter {
override fun shouldInclude(toolName: String): Boolean = toolName in allowedTools
}
}
@@ -6,6 +6,7 @@ import com.intellij.mcpserver.impl.util.network.*
import com.intellij.mcpserver.impl.util.projectPathParameterName
import com.intellij.mcpserver.settings.McpServerSettings
import com.intellij.mcpserver.statistics.McpServerCounterUsagesCollector
import com.intellij.mcpserver.stdio.IJ_MCP_ALLOWED_TOOLS
import com.intellij.mcpserver.stdio.IJ_MCP_SERVER_PROJECT_PATH
import com.intellij.mcpserver.util.findMostRelevantProject
import com.intellij.openapi.application.*
@@ -76,11 +77,6 @@ private fun getTracer(): IJTracer =
@Service(Service.Level.APP)
class McpServerService(val cs: CoroutineScope) {
companion object {
fun getInstance(): McpServerService = service()
suspend fun getInstanceAsync(): McpServerService = serviceAsync()
}
enum class AskCommandExecutionMode {
ASK,
DONT_ASK,
@@ -90,8 +86,15 @@ class McpServerService(val cs: CoroutineScope) {
*/
RESPECT_GLOBAL_SETTINGS,
}
class McpSessionOptions(
val commandExecutionMode: AskCommandExecutionMode,
val toolFilter: McpToolFilter = McpToolFilter.AllowAll
)
class McpSessionOptions(val commandExecutionMode: AskCommandExecutionMode)
companion object {
fun getInstance(): McpServerService = service()
suspend fun getInstanceAsync(): McpServerService = serviceAsync()
}
private val server = MutableStateFlow(startGlobalServerIfEnabled())
@@ -188,10 +191,12 @@ class McpServerService(val cs: CoroutineScope) {
}
}
private fun isKnownToken(token: String): Boolean = activeAuthorizedSessions.containsKey(token)
private fun isKnownToken(token: String): Boolean {
return activeAuthorizedSessions.containsKey(token)
}
private fun getSessionOptions(token: String?): McpSessionOptions {
return token?.let { activeAuthorizedSessions[it] }
return token?.let { activeAuthorizedSessions[token] }
?: McpSessionOptions(commandExecutionMode = AskCommandExecutionMode.RESPECT_GLOBAL_SETTINGS)
}
@@ -274,6 +279,23 @@ class McpServerService(val cs: CoroutineScope) {
}) { applicationCall, transport ->
// this is added because now a Kotlin MCP client doesn't support header adjusting for each request, only for initial one, see McpStdioRunner
val projectPath = applicationCall.request.headers[IJ_MCP_SERVER_PROJECT_PATH]
val authToken = if (authCheck) applicationCall.request.headers[IJ_MCP_AUTH_TOKEN] else null
// Check for tool filter from header (for stdio/CLI usage)
val allowedToolsFromHeader = applicationCall.request.headers[IJ_MCP_ALLOWED_TOOLS]
val headerFilter = allowedToolsFromHeader?.let { toolsStr ->
val tools = toolsStr.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet()
if (tools.isNotEmpty()) McpToolFilter.AllowList(tools) else McpToolFilter.AllowAll
}
// Merge filters: auth-based session options take precedence over header
val baseSessionOptions = getSessionOptions(authToken)
// if no header provided, use the existing filter from sessionOptions
val sessionOptions = if (headerFilter != null) {
McpSessionOptions(baseSessionOptions.commandExecutionMode, headerFilter)
} else {
baseSessionOptions
}
val mcpServer = Server(
Implementation(
name = "${ApplicationNamesInfo.getInstance().fullProductName} MCP Server",
@@ -292,18 +314,21 @@ class McpServerService(val cs: CoroutineScope) {
)
)
val session = mcpServer.createSession(transport)
//session.setRequestHandler<LoggingMessageNotification.SetLevelRequest>(Method.Defined.LoggingSetLevel) { _, _ ->
//session.setRequestHandler<LoggingMessageNotification.SetLevelRequest>(Method.Defined.LoggingSetLevel) { request, extra ->
// // Workaround inspector failure
// return@setRequestHandler EmptyRequestResult()
//}
launch {
var previousTools: List<McpTool>? = null
mcpTools.collectLatest { updatedTools ->
// Apply session-specific filter
val filteredTools = updatedTools.filter { sessionOptions.toolFilter.shouldInclude(it.descriptor.name) }
previousTools?.forEach { previousTool ->
mcpServer.removeTool(previousTool.descriptor.name)
}
mcpServer.addTools(updatedTools.map { it.mcpToolToRegisteredTool(mcpServer, session, projectPath) })
previousTools = updatedTools
mcpServer.addTools(filteredTools.map { it.mcpToolToRegisteredTool(mcpServer, session, projectPath) })
previousTools = filteredTools
}
}
return@mcpPatched session
@@ -311,14 +336,17 @@ class McpServerService(val cs: CoroutineScope) {
}.start(wait = false)
}
private fun getMcpTools() = McpToolsProvider.EP.extensionList.flatMap {
try {
it.getTools()
}
catch (e: Exception) {
logger.error("Cannot load tools for $it", e)
emptyList()
private fun getMcpTools(filter: McpToolFilter = McpToolFilter.AllowAll): List<McpTool> {
val allTools = McpToolsProvider.EP.extensionList.flatMap {
try {
it.getTools()
}
catch (e: Exception) {
logger.error("Cannot load tools for $it", e)
emptyList()
}
}
return allTools.filter { filter.shouldInclude(it.descriptor.name) }
}
private fun McpTool.mcpToolToRegisteredTool(
@@ -333,8 +361,6 @@ class McpServerService(val cs: CoroutineScope) {
httpRequest?.headers?.get(IJ_MCP_SERVER_PROJECT_PATH) ?: (request.meta?.get(IJ_MCP_SERVER_PROJECT_PATH) as? JsonPrimitive)?.content
?: projectPathFromInitialRequest
val projectPathFromMcpRequest = (request.arguments?.get(projectPathParameterName) as? JsonPrimitive)?.content
@Suppress("IncorrectCancellationExceptionHandling")
val project = try {
if (!projectPathFromMcpRequest.isNullOrBlank()) {
logger.trace { "Project path specified in MCP request: $projectPathFromMcpRequest" }
@@ -610,4 +636,4 @@ private fun McpTool.toSdkTool(): Tool {
outputSchema = outputSchema,
annotations = null)
return tool
}
}
@@ -0,0 +1,234 @@
@file:Suppress("TestFunctionName")
package com.intellij.mcpserver
import com.intellij.mcpserver.impl.McpServerService
import com.intellij.mcpserver.stdio.IJ_MCP_ALLOWED_TOOLS
import com.intellij.mcpserver.stdio.IJ_MCP_SERVER_PROJECT_PATH
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.fixture.moduleFixture
import com.intellij.testFramework.junit5.fixture.projectFixture
import io.kotest.common.runBlocking
import io.ktor.client.HttpClient
import io.ktor.client.plugins.sse.SSE
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.header
import io.modelcontextprotocol.kotlin.sdk.Implementation
import io.modelcontextprotocol.kotlin.sdk.client.Client
import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import org.junit.platform.commons.annotation.Testable
import kotlin.test.DefaultAsserter.assertNotNull
@Testable
@TestApplication
class McpToolFilterIntegrationTest {
private val projectFixture = projectFixture(openAfterCreation = true)
private val project by projectFixture
private val moduleFixture = projectFixture.moduleFixture("testModule")
private val tool1 = "list_directory_tree"
private val tool2 = "open_file_in_editor"
private val tool3 = "get_file_text_by_path"
@Test
fun `server exposes all tools with AllowAll filter`() = runBlocking {
withConnection(filter = McpToolFilter.AllowAll) { client, _ ->
val tools = client.listTools().tools
// Should have all available tools
assertTrue(tools.size >= 10, "Should expose multiple tools with AllowAll filter")
// Verify some common tools are present
assertTrue(tools.any { it.name == tool1 }, "Should have $tool1 tool")
assertTrue(tools.any { it.name == tool2 }, "Should have $tool2 tool")
}
}
@Test
fun `server filters tools with AllowList filter`() = runBlocking {
val allowedTools = setOf(tool1, tool2)
val filter = McpToolFilter.AllowList(allowedTools)
withConnection(filter = filter) { client, _ ->
val tools = client.listTools().tools
// Should only have allowed tools
assertEquals(2, tools.size, "Should only expose allowed tools")
// Verify only allowed tools are present
val toolNames = tools.map { it.name }.toSet()
assertEquals(allowedTools, toolNames, "Should only have allowed tools")
// Verify filtered tools are not present
assertFalse(tools.any { it.name == tool3 }, "Should not have git_commit")
}
}
@Test
fun `server filters tools with header-based filter`() = runBlocking {
val allowedToolsHeader = "$tool1,$tool2,$tool3"
withConnectionUsingHeader(allowedToolsHeader = allowedToolsHeader) { client ->
val tools = client.listTools().tools
// Should only have tools from header
assertEquals(3, tools.size, "Should only expose tools from header")
val toolNames = tools.map { it.name }.toSet()
assertEquals(setOf(tool1, tool2, tool3), toolNames)
}
}
@Test
fun `server handles malformed header gracefully`() = runBlocking {
// Test with spaces and empty entries
val allowedToolsHeader = " $tool1 , , $tool2 , "
withConnectionUsingHeader(allowedToolsHeader = allowedToolsHeader) { client ->
val tools = client.listTools().tools
// Should filter out empty entries and trim spaces
assertEquals(2, tools.size, "Should handle malformed header")
val toolNames = tools.map { it.name }.toSet()
assertEquals(setOf(tool1, tool2), toolNames)
}
}
@Test
fun `server exposes no tools with empty AllowList`() = runBlocking {
val filter = McpToolFilter.AllowList(emptySet())
withConnection(filter = filter) { client, _ ->
val tools = client.listTools().tools
// Should have no tools
assertEquals(0, tools.size, "Should expose no tools with empty allow list")
}
}
@Test
fun `filtered tools cannot be called`() = runBlocking {
val filter = McpToolFilter.AllowList(setOf(tool1))
withConnection(filter = filter) { client, _ ->
val tools = client.listTools().tools
// Only read_file should be available
assertEquals(1, tools.size)
assertEquals(tool1, tools[0].name)
// Attempting to call a filtered-out tool should fail
// (The tool won't be registered, so the SDK should return an error)
val result = try {
client.callTool(tool2, kotlinx.serialization.json.buildJsonObject {
put("filePath", kotlinx.serialization.json.JsonPrimitive("test.txt"))
})
null
} catch (_: Exception) {
"error" // Expected to fail
}
assertNotNull(result, "Calling filtered tool should fail")
}
}
private suspend fun withConnection(
filter: McpToolFilter = McpToolFilter.AllowAll,
allowedToolsHeader: String? = null,
action: suspend (Client, List<String>) -> Unit
) {
require(allowedToolsHeader == null || filter == McpToolFilter.AllowAll) {
"Cannot use both filter and allowedToolsHeader - specify only one"
}
if (allowedToolsHeader != null) {
// Use global server with header-based filtering
withGlobalServerConnection(allowedToolsHeader) { client ->
action(client, emptyList())
}
} else {
// Use authorized session with filter-based filtering
withAuthorizedSessionConnection(filter) { client ->
action(client, emptyList())
}
}
}
private suspend fun withConnectionUsingHeader(
allowedToolsHeader: String,
action: suspend (Client) -> Unit
) {
withConnection(allowedToolsHeader = allowedToolsHeader) { client, _ ->
action(client)
}
}
private suspend fun withAuthorizedSessionConnection(
filter: McpToolFilter,
action: suspend (Client) -> Unit
) {
val service = McpServerService.getInstance()
service.authorizedSession(
McpServerService.McpSessionOptions(
commandExecutionMode = McpServerService.AskCommandExecutionMode.DONT_ASK,
toolFilter = filter
)
) { port, tokenName, tokenValue ->
connectToServer(
port = port,
extraHeaders = {
header(tokenName, tokenValue)
},
action = action
)
}
}
private suspend fun withGlobalServerConnection(
allowedToolsHeader: String,
action: suspend (Client) -> Unit
) {
McpServerService.getInstance().start()
try {
val port = McpServerService.getInstance().port
connectToServer(
port = port,
extraHeaders = {
header(IJ_MCP_ALLOWED_TOOLS, allowedToolsHeader)
},
action = action
)
} finally {
McpServerService.getInstance().stop()
}
}
private suspend fun connectToServer(
port: Int,
extraHeaders: HttpRequestBuilder.() -> Unit,
action: suspend (Client) -> Unit
) {
val httpClient = HttpClient {
install(SSE)
}
val sseClientTransport = SseClientTransport(httpClient, "http://localhost:$port/sse") {
header(IJ_MCP_SERVER_PROJECT_PATH, project.basePath)
extraHeaders()
}
val client = Client(Implementation(name = "test client", version = "1.0"))
try {
client.connect(sseClientTransport)
action(client)
} finally {
sseClientTransport.close()
}
}
}
@@ -0,0 +1,55 @@
package com.intellij.mcpserver
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class McpToolFilterTest {
@Test
fun `AllowAll filter includes all tools`() {
val filter = McpToolFilter.AllowAll
assertTrue(filter.shouldInclude("read_file"))
assertTrue(filter.shouldInclude("write_file"))
assertTrue(filter.shouldInclude("execute_command"))
assertTrue(filter.shouldInclude("any_tool_name"))
assertTrue(filter.shouldInclude(""))
}
@Test
fun `AllowList filter includes allowed tools`() {
val filter = McpToolFilter.AllowList(setOf("read_file", "write_file", "grep"))
assertTrue(filter.shouldInclude("read_file"))
assertTrue(filter.shouldInclude("write_file"))
assertTrue(filter.shouldInclude("grep"))
}
@Test
fun `AllowList filter excludes non-allowed tools`() {
val filter = McpToolFilter.AllowList(setOf("read_file", "write_file"))
assertFalse(filter.shouldInclude("execute_command"))
assertFalse(filter.shouldInclude("git_commit"))
assertFalse(filter.shouldInclude("delete_file"))
}
@Test
fun `AllowList filter with empty set excludes all tools`() {
val filter = McpToolFilter.AllowList(emptySet())
assertFalse(filter.shouldInclude("read_file"))
assertFalse(filter.shouldInclude("write_file"))
assertFalse(filter.shouldInclude("any_tool"))
}
@Test
fun `AllowList filter is case sensitive`() {
val filter = McpToolFilter.AllowList(setOf("read_file"))
assertTrue(filter.shouldInclude("read_file"))
assertFalse(filter.shouldInclude("Read_File"))
assertFalse(filter.shouldInclude("READ_FILE"))
}
}