From 9152bc3bfac608b1a18b3b62665620afeba7e26b Mon Sep 17 00:00:00 2001 From: "Anna.Zhdan" Date: Tue, 16 Dec 2025 17:20:27 +0100 Subject: [PATCH] LLM-22191 [ACP] provide a list of available idea mcp tools in acp.json merged GitOrigin-RevId: 53f36b6c287860a78d36bec22235d7b9e6197b84 --- .../mcpserver.stdio/src/McpStdioRunner.kt | 7 + .../com/intellij/mcpserver/McpToolFilter.kt | 40 +++ .../mcpserver/impl/McpServerService.kt | 68 +++-- .../mcpserver/McpToolFilterIntegrationTest.kt | 234 ++++++++++++++++++ .../intellij/mcpserver/McpToolFilterTest.kt | 55 ++++ 5 files changed, 383 insertions(+), 21 deletions(-) create mode 100644 plugins/mcp-server/src/com/intellij/mcpserver/McpToolFilter.kt create mode 100644 plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterIntegrationTest.kt create mode 100644 plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterTest.kt diff --git a/plugins/mcp-server/mcpserver.stdio/src/McpStdioRunner.kt b/plugins/mcp-server/mcpserver.stdio/src/McpStdioRunner.kt index 01896c8738d2..234fa78f4c25 100644 --- a/plugins/mcp-server/mcpserver.stdio/src/McpStdioRunner.kt +++ b/plugins/mcp-server/mcpserver.stdio/src/McpStdioRunner.kt @@ -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 diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/McpToolFilter.kt b/plugins/mcp-server/src/com/intellij/mcpserver/McpToolFilter.kt new file mode 100644 index 000000000000..137b96cf7ef9 --- /dev/null +++ b/plugins/mcp-server/src/com/intellij/mcpserver/McpToolFilter.kt @@ -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) : McpToolFilter { + override fun shouldInclude(toolName: String): Boolean = toolName in allowedTools + } +} diff --git a/plugins/mcp-server/src/com/intellij/mcpserver/impl/McpServerService.kt b/plugins/mcp-server/src/com/intellij/mcpserver/impl/McpServerService.kt index c94253c5dcc6..56d4b94a5e3a 100644 --- a/plugins/mcp-server/src/com/intellij/mcpserver/impl/McpServerService.kt +++ b/plugins/mcp-server/src/com/intellij/mcpserver/impl/McpServerService.kt @@ -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(Method.Defined.LoggingSetLevel) { _, _ -> + //session.setRequestHandler(Method.Defined.LoggingSetLevel) { request, extra -> // // Workaround inspector failure // return@setRequestHandler EmptyRequestResult() //} launch { var previousTools: List? = 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 { + 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 -} +} \ No newline at end of file diff --git a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterIntegrationTest.kt b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterIntegrationTest.kt new file mode 100644 index 000000000000..6aa43474076a --- /dev/null +++ b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterIntegrationTest.kt @@ -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) -> 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() + } + } +} diff --git a/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterTest.kt b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterTest.kt new file mode 100644 index 000000000000..0bdf6560962e --- /dev/null +++ b/plugins/mcp-server/tests/testSrc/com/intellij/mcpserver/McpToolFilterTest.kt @@ -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")) + } +}