Skip to content

Commit fb53c52

Browse files
committed
feat(mcp): add MCP server support and configuration #33-
- Add MCP server enable/disable setting in AutoDevCoderConfigurable. - Implement MCPServerStartupValidator for MCP server validation. - Add ClaudeConfigManager for handling MCP server proxy configuration. - Update README to include MCP server reference.
1 parent caacabb commit fb53c52

File tree

10 files changed

+332
-7
lines changed

10 files changed

+332
-7
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,5 +173,6 @@ Inspired by:
173173
which is licensed under the Apache 2.0 license.
174174
- Stream Diff based on [Continue Dev](https://github.com/continuedev/continue) under the Apache 2.0 license.
175175
- Ripgrep inspired by [Cline](https://github.com/cline/cline) under the Apache 2.0 license.
176+
- MCP based on [JetBrains' MCP](https://plugins.jetbrains.com/plugin/26071-mcp-server)
176177

177178
This code is distributed under the MPL 2.0 license. See `LICENSE` in this directory.

core/src/223/main/resources/META-INF/autodev-core.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
implementation="cc.unitmesh.devti.gui.snippet.error.CodeBlockIntentionActionFilter"/>
8181

8282
<httpRequestHandler implementation="cc.unitmesh.devti.mcp.MCPService"/>
83+
<notificationGroup id="UnitMesh.MCPServer" displayType="BALLOON"/>
8384
</extensions>
8485

8586
<extensions defaultExtensionNs="JavaScript.JsonSchema">

core/src/233/main/resources/META-INF/autodev-core.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
implementation="cc.unitmesh.devti.gui.snippet.error.CodeBlockIntentionActionFilter"/>
8080

8181
<httpRequestHandler implementation="cc.unitmesh.devti.mcp.MCPService"/>
82+
<postStartupActivity implementation="cc.unitmesh.devti.mcp.MCPServerStartupValidator"/>
83+
<notificationGroup id="UnitMesh.MCPServer" displayType="BALLOON"/>
8284
</extensions>
8385

8486
<extensions defaultExtensionNs="JavaScript.JsonSchema">

core/src/main/kotlin/cc/unitmesh/devti/mcp/BuiltinMcpTools.kt

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import com.intellij.openapi.progress.impl.CoreProgressManager
2424
import com.intellij.openapi.project.Project
2525
import com.intellij.openapi.project.guessProjectDir
2626
import com.intellij.openapi.roots.OrderEnumerator
27+
import com.intellij.openapi.util.io.FileUtilRt
2728
import com.intellij.openapi.vfs.LocalFileSystem
29+
import com.intellij.openapi.vfs.VfsUtilCore
2830
import com.intellij.openapi.vfs.VirtualFile
29-
import com.intellij.openapi.vfs.readText
30-
import com.intellij.openapi.vfs.toNioPathOrNull
3131
import com.intellij.psi.PsiDocumentManager
3232
import com.intellij.psi.search.FilenameIndex
3333
import com.intellij.psi.search.GlobalSearchScope
@@ -38,7 +38,9 @@ import com.intellij.util.Processor
3838
import com.intellij.util.application
3939
import com.intellij.util.io.createParentDirectories
4040
import kotlinx.serialization.Serializable
41+
import java.nio.file.InvalidPathException
4142
import java.nio.file.Path
43+
import java.nio.file.Paths
4244
import java.util.concurrent.TimeUnit
4345
import kotlin.io.path.*
4446

@@ -713,4 +715,21 @@ class WaitTool : AbstractMcpTool<WaitArgs>() {
713715

714716
return Response("ok")
715717
}
716-
}
718+
}
719+
720+
fun VirtualFile.toNioPathOrNull(): Path? {
721+
return runCatching { toNioPath() }.getOrNull()
722+
}
723+
724+
fun String.toNioPathOrNull(): Path? {
725+
return try {
726+
Paths.get(FileUtilRt.toSystemDependentName(this))
727+
}
728+
catch (ex: InvalidPathException) {
729+
null
730+
}
731+
}
732+
733+
fun VirtualFile.readText(): String {
734+
return VfsUtilCore.loadText(this)
735+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2+
package cc.unitmesh.devti.mcp
3+
4+
import cc.unitmesh.devti.mcp.claude.ClaudeConfigManager
5+
import cc.unitmesh.devti.settings.coder.coderSetting
6+
import com.intellij.execution.configurations.GeneralCommandLine
7+
import com.intellij.execution.process.OSProcessHandler
8+
import com.intellij.execution.process.ProcessAdapter
9+
import com.intellij.execution.process.ProcessEvent
10+
import com.intellij.execution.process.ProcessOutputTypes
11+
import com.intellij.ide.BrowserUtil
12+
import com.intellij.notification.NotificationAction
13+
import com.intellij.notification.NotificationGroupManager
14+
import com.intellij.notification.NotificationType
15+
import com.intellij.openapi.diagnostic.logger
16+
import com.intellij.openapi.project.Project
17+
import com.intellij.openapi.startup.ProjectActivity
18+
import com.intellij.openapi.util.Key
19+
import com.intellij.openapi.util.SystemInfo
20+
import java.io.File
21+
22+
internal class MCPServerStartupValidator : ProjectActivity {
23+
private val GROUP_ID = "UnitMesh.MCPServer"
24+
25+
val logger by lazy { logger<MCPServerStartupValidator>() }
26+
27+
fun isNpxInstalled(): Boolean {
28+
return try {
29+
logger.info("Starting npx installation check")
30+
if (SystemInfo.isWindows) {
31+
logger.info("Detected Windows OS, using 'where' command")
32+
checkNpxWindows()
33+
} else {
34+
logger.info("Detected non-Windows OS, checking known locations")
35+
checkNpxUnix()
36+
}
37+
} catch (e: Exception) {
38+
logger.error("Failed to check npx installation", e)
39+
logger.error("Exception details - Class: ${e.javaClass.name}, Message: ${e.message}")
40+
false
41+
}
42+
}
43+
44+
private fun checkNpxWindows(): Boolean {
45+
val commandLine = GeneralCommandLine("where", "npx")
46+
logger.info("Windows - Environment PATH: ${commandLine.environment["PATH"]}")
47+
48+
val handler = OSProcessHandler(commandLine)
49+
val output = StringBuilder()
50+
val error = StringBuilder()
51+
52+
handler.addProcessListener(object : ProcessAdapter() {
53+
override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
54+
when (outputType) {
55+
ProcessOutputTypes.STDOUT -> output.append(event.text)
56+
ProcessOutputTypes.STDERR -> error.append(event.text)
57+
}
58+
}
59+
})
60+
61+
handler.startNotify()
62+
val completed = handler.waitFor(5000)
63+
64+
logger.info("Windows - where npx completed with success: $completed")
65+
if (output.isNotBlank()) logger.info("Windows - Output: $output")
66+
if (error.isNotBlank()) logger.warn("Windows - Error: $error")
67+
68+
return completed && handler.exitCode == 0
69+
}
70+
71+
private fun checkNpxUnix(): Boolean {
72+
// First try checking known locations including user-specific installations
73+
val homeDir = System.getProperty("user.home")
74+
val knownPaths = listOf(
75+
"/opt/homebrew/bin/npx",
76+
"/usr/local/bin/npx",
77+
"/usr/bin/npx",
78+
"$homeDir/.volta/bin/npx", // Volta installation
79+
"$homeDir/.nvm/current/bin/npx", // NVM installation
80+
"$homeDir/.npm-global/bin/npx" // NPM global installation
81+
)
82+
83+
logger.info("Unix - Checking known npx locations: ${knownPaths.joinToString(", ")}")
84+
85+
val existingPath = knownPaths.find { path ->
86+
File(path).also {
87+
logger.info("Unix - Checking path: $path exists: ${it.exists()}")
88+
}.exists()
89+
}
90+
91+
if (existingPath != null) {
92+
logger.info("Unix - Found npx at: $existingPath")
93+
return true
94+
}
95+
96+
// Fallback to which command with extended PATH
97+
logger.info("Unix - No npx found in known locations, trying which command")
98+
val commandLine = GeneralCommandLine("which", "npx")
99+
100+
// Add all potential paths to PATH
101+
val currentPath = System.getenv("PATH") ?: ""
102+
val additionalPaths = listOf(
103+
"/opt/homebrew/bin",
104+
"/opt/homebrew/sbin",
105+
"/usr/local/bin",
106+
"$homeDir/.volta/bin",
107+
"$homeDir/.nvm/current/bin",
108+
"$homeDir/.npm-global/bin"
109+
).joinToString(":")
110+
commandLine.environment["PATH"] = "$additionalPaths:$currentPath"
111+
logger.info("Unix - Modified PATH for which command: ${commandLine.environment["PATH"]}")
112+
113+
val handler = OSProcessHandler(commandLine)
114+
val output = StringBuilder()
115+
val error = StringBuilder()
116+
117+
handler.addProcessListener(object : ProcessAdapter() {
118+
override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
119+
when (outputType) {
120+
ProcessOutputTypes.STDOUT -> output.append(event.text)
121+
ProcessOutputTypes.STDERR -> error.append(event.text)
122+
}
123+
}
124+
})
125+
126+
handler.startNotify()
127+
val completed = handler.waitFor(5000)
128+
129+
logger.info("Unix - which npx completed with success: $completed")
130+
logger.info("Unix - which npx completed with code: ${handler.exitCode}")
131+
if (output.isNotBlank()) logger.info("Unix - Output: $output")
132+
if (error.isNotBlank()) logger.warn("Unix - Error: $error")
133+
134+
return completed && handler.exitCode == 0
135+
}
136+
137+
override suspend fun execute(project: Project) {
138+
val notificationGroup = NotificationGroupManager.getInstance().getNotificationGroup(GROUP_ID)
139+
if (SystemInfo.isLinux) {
140+
logger.info("No Claude Client on Linux, skipping validation")
141+
return
142+
}
143+
if (!project.coderSetting.state.enableMcpServer) {
144+
logger.info("MCP Server is disabled, skipping validation")
145+
return
146+
}
147+
148+
if (!ClaudeConfigManager.isClaudeClientInstalled()) {
149+
val notification = notificationGroup.createNotification(
150+
"Claude Client is not installed",
151+
NotificationType.INFORMATION
152+
)
153+
notification.addAction(NotificationAction.createSimpleExpiring("Open Installation Instruction") {
154+
BrowserUtil.open("https://claude.ai/download")
155+
})
156+
notification.notify(project)
157+
}
158+
159+
val npxInstalled = isNpxInstalled()
160+
if (!npxInstalled) {
161+
val notification = notificationGroup.createNotification(
162+
"Node is not installed",
163+
"MCP Server Proxy requires Node.js to be installed",
164+
NotificationType.INFORMATION
165+
)
166+
notification.addAction(NotificationAction.createSimpleExpiring("Open Installation Instruction") {
167+
BrowserUtil.open("https://nodejs.org/en/download/package-manager")
168+
})
169+
170+
notification.notify(project)
171+
}
172+
173+
if (ClaudeConfigManager.isClaudeClientInstalled() && npxInstalled && !ClaudeConfigManager.isProxyConfigured()) {
174+
val notification = notificationGroup.createNotification(
175+
"MCP Server Proxy is not configured",
176+
NotificationType.INFORMATION
177+
)
178+
notification.addAction(NotificationAction.createSimpleExpiring("Install MCP Server Proxy") {
179+
ClaudeConfigManager.modifyClaudeSettings()
180+
})
181+
notification.notify(project)
182+
}
183+
}
184+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright 2000-2021 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2+
package cc.unitmesh.devti.mcp.claude
3+
4+
import com.google.gson.Gson
5+
import com.google.gson.GsonBuilder
6+
import com.google.gson.JsonObject
7+
import com.google.gson.JsonParser
8+
import com.intellij.openapi.util.SystemInfo
9+
import java.nio.file.Path
10+
import java.nio.file.Paths
11+
import kotlin.io.path.exists
12+
13+
object ClaudeConfigManager {
14+
private val configPath: Path = getConfigPath()
15+
private val gson = Gson()
16+
17+
fun isProxyConfigured(): Boolean {
18+
return getExistingJsonObject()?.let { json ->
19+
json.getAsJsonObject("mcpServers")?.let { servers ->
20+
isProxyInServers(servers)
21+
}
22+
} ?: false
23+
}
24+
25+
fun modifyClaudeSettings() {
26+
val configFile = configPath.toFile()
27+
if (!configFile.exists()) {
28+
configFile.parentFile.mkdirs()
29+
configFile.createNewFile()
30+
}
31+
32+
val jsonObject = getExistingJsonObject() ?: JsonObject()
33+
34+
if (!jsonObject.has("mcpServers")) {
35+
jsonObject.add("mcpServers", JsonObject())
36+
}
37+
38+
val mcpServers = jsonObject.getAsJsonObject("mcpServers")
39+
40+
if (!isProxyInServers(mcpServers)) {
41+
val jetbrainsConfig = JsonObject().apply {
42+
addProperty("command", "npx")
43+
add("args", gson.toJsonTree(arrayOf("-y", "@jetbrains/mcp-proxy")))
44+
}
45+
mcpServers.add("jetbrains", jetbrainsConfig)
46+
47+
try {
48+
val prettyGson = GsonBuilder().setPrettyPrinting().create()
49+
configFile.writeText(prettyGson.toJson(jsonObject))
50+
} catch (e: Exception) {
51+
throw RuntimeException("Failed to write configuration file", e)
52+
}
53+
}
54+
}
55+
56+
fun isClaudeClientInstalled(): Boolean {
57+
return getClaudeConfigPath().exists()
58+
}
59+
60+
private fun getClaudeConfigPath(): Path {
61+
return when {
62+
SystemInfo.isMac ->
63+
Paths.get(System.getProperty("user.home"), "Library", "Application Support", "Claude")
64+
65+
SystemInfo.isWindows ->
66+
Paths.get(System.getenv("APPDATA"), "Claude")
67+
68+
else -> throw IllegalStateException("Unsupported operating system")
69+
}
70+
}
71+
72+
private fun getConfigPath(): Path {
73+
return getClaudeConfigPath().resolve("claude_desktop_config.json")
74+
}
75+
76+
private fun getExistingJsonObject(): JsonObject? {
77+
val configFile = configPath.toFile()
78+
if (!configFile.exists()) {
79+
return null
80+
}
81+
82+
val jsonContent = try {
83+
configFile.readText()
84+
} catch (e: Exception) {
85+
return null
86+
}
87+
88+
return try {
89+
JsonParser.parseString(jsonContent).asJsonObject
90+
} catch (e: Exception) {
91+
null
92+
}
93+
}
94+
95+
private fun isProxyInServers(mcpServers: JsonObject): Boolean {
96+
for (serverEntry in mcpServers.entrySet()) {
97+
val serverConfig = serverEntry.value.asJsonObject
98+
if (serverConfig.has("command") && serverConfig.has("args")) {
99+
val command = serverConfig.get("command").asString
100+
val args = serverConfig.getAsJsonArray("args")
101+
if (command == "npx" && args.any { it.asString.contains("@jetbrains/mcp-proxy") }) {
102+
return true
103+
}
104+
}
105+
}
106+
return false
107+
}
108+
}

core/src/main/kotlin/cc/unitmesh/devti/settings/coder/AutoDevCoderConfigurable.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class AutoDevCoderConfigurable(private val project: Project) : BoundConfigurable
2020
}
2121
private val inEditorCompletionCheckBox = JCheckBox()
2222
private val noChatHistoryCheckBox = JCheckBox()
23+
private val enableMcpServerCheckBox = JCheckBox()
2324
private val teamPromptsField = JTextField()
2425
private val trimCodeBeforeSend = JCheckBox()
2526

@@ -55,6 +56,15 @@ class AutoDevCoderConfigurable(private val project: Project) : BoundConfigurable
5556
)
5657
}
5758

59+
row(jLabel("settings.autodev.coder.enableMcpServer")) {
60+
fullWidthCell(enableMcpServerCheckBox)
61+
.bind(
62+
componentGet = { it.isSelected },
63+
componentSet = { component, value -> component.isSelected = value },
64+
prop = state::enableMcpServer.toMutableProperty()
65+
)
66+
}
67+
5868
row(jLabel("settings.autodev.coder.trimCodeBeforeSend")) {
5969
fullWidthCell(trimCodeBeforeSend)
6070
.bind(
@@ -100,6 +110,7 @@ class AutoDevCoderConfigurable(private val project: Project) : BoundConfigurable
100110
it.enableRenameSuggestion = state.enableRenameSuggestion
101111
it.trimCodeBeforeSend = state.trimCodeBeforeSend
102112
it.teamPromptsDir = state.teamPromptsDir
113+
it.enableMcpServer = state.enableMcpServer
103114
}
104115
}
105116
}

0 commit comments

Comments
 (0)