mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
LLM-22191 [ACP] provide a list of available idea mcp tools in acp.json merged
GitOrigin-RevId: 53f36b6c287860a78d36bec22235d7b9e6197b84
This commit is contained in:
committed by
intellij-monorepo-bot
parent
fc0ff807b9
commit
9152bc3bfa
@@ -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
|
||||
}
|
||||
}
|
||||
+234
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user