From 5e021e0e9cabac8cabcf58861824bea21980d314 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:32:57 +0000 Subject: [PATCH 01/27] Initial plan From 385de849cb6ec26aed3e7859ac1239a9d3ece0ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:45:02 +0000 Subject: [PATCH 02/27] Add Remote MCP Server support with UI Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../dark/tool_neuron/activity/MainActivity.kt | 11 + .../dark/tool_neuron/database/AppDatabase.kt | 32 +- .../tool_neuron/database/dao/McpServerDao.kt | 42 + .../com/dark/tool_neuron/di/HiltModules.kt | 21 + .../models/converters/Converters.kt | 14 + .../models/table_schema/McpServer.kt | 72 ++ .../tool_neuron/repo/McpServerRepository.kt | 123 +++ .../tool_neuron/service/McpClientService.kt | 248 ++++++ .../tool_neuron/ui/screen/McpServersScreen.kt | 831 ++++++++++++++++++ .../ui/screen/home_screen/HomeDrawerScreen.kt | 9 + .../ui/screen/home_screen/HomeScreen.kt | 24 +- .../viewmodel/McpServerViewModel.kt | 268 ++++++ 12 files changed, 1685 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt diff --git a/app/src/main/java/com/dark/tool_neuron/activity/MainActivity.kt b/app/src/main/java/com/dark/tool_neuron/activity/MainActivity.kt index bcb6a4aa..678a7b6d 100644 --- a/app/src/main/java/com/dark/tool_neuron/activity/MainActivity.kt +++ b/app/src/main/java/com/dark/tool_neuron/activity/MainActivity.kt @@ -26,6 +26,7 @@ import com.dark.tool_neuron.data.TermsDataStore import com.dark.tool_neuron.di.AppContainer import com.dark.tool_neuron.engine.EmbeddingEngine import com.dark.tool_neuron.ui.screen.EmbeddingSetupScreen +import com.dark.tool_neuron.ui.screen.McpServersScreen import com.dark.tool_neuron.ui.screen.ModelConfigEditorScreen import com.dark.tool_neuron.ui.screen.ModelStoreScreen import com.dark.tool_neuron.ui.screen.TermsAndConditionsScreen @@ -121,6 +122,7 @@ sealed class Screen(val route: String) { object Store : Screen("store") object Editor : Screen("editor") object VaultManager: Screen("vault_manager") + object McpServers: Screen("mcp_servers") } @Composable @@ -176,6 +178,9 @@ fun AppNavigation( onVaultManagerClick = { navController.navigate(Screen.VaultManager.route) }, + onMcpServersClick = { + navController.navigate(Screen.McpServers.route) + }, chatViewModel = chatViewModel, llmModelViewModel = llmModelViewModel ) @@ -196,5 +201,11 @@ fun AppNavigation( composable(Screen.VaultManager.route) { VaultDashboard() } + + composable(Screen.McpServers.route) { + McpServersScreen(onBackClick = { + navController.popBackStack() + }) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt b/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt index 8ac7b2d0..4a0bfdbf 100644 --- a/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt +++ b/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt @@ -7,17 +7,19 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.dark.tool_neuron.database.dao.McpServerDao import com.dark.tool_neuron.database.dao.ModelConfigDao import com.dark.tool_neuron.database.dao.ModelDao import com.dark.tool_neuron.database.dao.RagDao import com.dark.tool_neuron.models.converters.Converters import com.dark.tool_neuron.models.table_schema.InstalledRag +import com.dark.tool_neuron.models.table_schema.McpServer import com.dark.tool_neuron.models.table_schema.Model import com.dark.tool_neuron.models.table_schema.ModelConfig @Database( - entities = [Model::class, ModelConfig::class, InstalledRag::class], - version = 4, + entities = [Model::class, ModelConfig::class, InstalledRag::class, McpServer::class], + version = 5, exportSchema = false ) @TypeConverters(Converters::class) @@ -25,6 +27,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun modelDao(): ModelDao abstract fun modelConfigDao(): ModelConfigDao abstract fun ragDao(): RagDao + abstract fun mcpServerDao(): McpServerDao companion object { @Volatile @@ -114,6 +117,29 @@ abstract class AppDatabase : RoomDatabase() { } } + private val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + // Create mcp_servers table + db.execSQL(""" + CREATE TABLE IF NOT EXISTS mcp_servers ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + url TEXT NOT NULL, + transportType TEXT NOT NULL, + apiKey TEXT, + isEnabled INTEGER NOT NULL, + connectionStatus TEXT NOT NULL, + lastError TEXT, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + lastConnectedAt INTEGER, + description TEXT NOT NULL, + customHeadersJson TEXT + ) + """.trimIndent()) + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( @@ -121,7 +147,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "llm_models_database" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .fallbackToDestructiveMigration() .build() INSTANCE = instance diff --git a/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt b/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt new file mode 100644 index 00000000..d06d3e66 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt @@ -0,0 +1,42 @@ +package com.dark.tool_neuron.database.dao + +import androidx.room.* +import com.dark.tool_neuron.models.table_schema.McpServer +import kotlinx.coroutines.flow.Flow + +@Dao +interface McpServerDao { + + @Query("SELECT * FROM mcp_servers ORDER BY name ASC") + fun getAllServers(): Flow> + + @Query("SELECT * FROM mcp_servers WHERE isEnabled = 1 ORDER BY name ASC") + fun getEnabledServers(): Flow> + + @Query("SELECT * FROM mcp_servers WHERE id = :id") + suspend fun getServerById(id: String): McpServer? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertServer(server: McpServer) + + @Update + suspend fun updateServer(server: McpServer) + + @Delete + suspend fun deleteServer(server: McpServer) + + @Query("DELETE FROM mcp_servers WHERE id = :id") + suspend fun deleteServerById(id: String) + + @Query("UPDATE mcp_servers SET isEnabled = :isEnabled, updatedAt = :updatedAt WHERE id = :id") + suspend fun updateServerEnabled(id: String, isEnabled: Boolean, updatedAt: Long = System.currentTimeMillis()) + + @Query("UPDATE mcp_servers SET lastConnectedAt = :timestamp, updatedAt = :updatedAt WHERE id = :id") + suspend fun updateLastConnected(id: String, timestamp: Long, updatedAt: Long = System.currentTimeMillis()) + + @Query("SELECT COUNT(*) FROM mcp_servers") + fun getServerCount(): Flow + + @Query("SELECT COUNT(*) FROM mcp_servers WHERE isEnabled = 1") + fun getEnabledServerCount(): Flow +} diff --git a/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt b/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt index e8765672..8be54ff1 100644 --- a/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt +++ b/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt @@ -4,8 +4,10 @@ import com.dark.tool_neuron.database.AppDatabase import com.dark.tool_neuron.engine.EmbeddingEngine import com.dark.tool_neuron.repo.ChatRepository + import com.dark.tool_neuron.repo.McpServerRepository import com.dark.tool_neuron.repo.ModelRepository import com.dark.tool_neuron.repo.RagRepository + import com.dark.tool_neuron.service.McpClientService import com.dark.tool_neuron.worker.ChatManager import com.dark.tool_neuron.worker.GenerationManager import com.dark.tool_neuron.worker.RagVaultIntegration @@ -65,6 +67,14 @@ context = context ) } + + @Provides + @Singleton + fun provideMcpServerRepository(database: AppDatabase): McpServerRepository { + return McpServerRepository( + mcpServerDao = database.mcpServerDao() + ) + } } @Module @@ -78,6 +88,17 @@ } } + @Module + @InstallIn(SingletonComponent::class) + object ServiceModule { + + @Provides + @Singleton + fun provideMcpClientService(): McpClientService { + return McpClientService() + } + } + @Module @InstallIn(SingletonComponent::class) object WorkerModule { diff --git a/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt b/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt index d801f7f3..dc0a5fba 100644 --- a/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt +++ b/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt @@ -3,6 +3,8 @@ package com.dark.tool_neuron.models.converters import androidx.room.TypeConverter import com.dark.tool_neuron.models.enums.PathType import com.dark.tool_neuron.models.enums.ProviderType +import com.dark.tool_neuron.models.table_schema.McpConnectionStatus +import com.dark.tool_neuron.models.table_schema.McpTransportType class Converters { @TypeConverter @@ -16,4 +18,16 @@ class Converters { @TypeConverter fun toPathType(value: String): PathType = PathType.valueOf(value) + + @TypeConverter + fun fromMcpTransportType(value: McpTransportType): String = value.name + + @TypeConverter + fun toMcpTransportType(value: String): McpTransportType = McpTransportType.valueOf(value) + + @TypeConverter + fun fromMcpConnectionStatus(value: McpConnectionStatus): String = value.name + + @TypeConverter + fun toMcpConnectionStatus(value: String): McpConnectionStatus = McpConnectionStatus.valueOf(value) } \ No newline at end of file diff --git a/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt new file mode 100644 index 00000000..40e7510b --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt @@ -0,0 +1,72 @@ +package com.dark.tool_neuron.models.table_schema + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Transport type for MCP server connections + */ +enum class McpTransportType { + SSE, // Server-Sent Events (HTTP) + STREAMABLE_HTTP // Streamable HTTP transport +} + +/** + * Connection status of an MCP server + */ +enum class McpConnectionStatus { + DISCONNECTED, + CONNECTING, + CONNECTED, + ERROR +} + +/** + * Entity representing a remote MCP (Model Context Protocol) server configuration. + * MCP servers provide tools, resources, and prompts to LLM applications. + */ +@Entity(tableName = "mcp_servers") +data class McpServer( + @PrimaryKey + val id: String, + + /** Display name for the server */ + val name: String, + + /** Server URL (e.g., "https://api.example.com/mcp") */ + val url: String, + + /** Transport type for the connection */ + val transportType: McpTransportType = McpTransportType.SSE, + + /** Optional API key for authentication */ + val apiKey: String? = null, + + /** Whether the server is enabled */ + val isEnabled: Boolean = true, + + /** Current connection status (not persisted, managed at runtime) */ + val connectionStatus: McpConnectionStatus = McpConnectionStatus.DISCONNECTED, + + /** Last error message if connection failed */ + val lastError: String? = null, + + /** Timestamp when the server was added */ + val createdAt: Long = System.currentTimeMillis(), + + /** Timestamp when the server was last modified */ + val updatedAt: Long = System.currentTimeMillis(), + + /** Timestamp when last successfully connected */ + val lastConnectedAt: Long? = null, + + /** Optional description */ + val description: String = "", + + /** Custom headers as JSON string (e.g., for additional auth) */ + val customHeadersJson: String? = null +) { + companion object { + fun generateId(): String = java.util.UUID.randomUUID().toString() + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt new file mode 100644 index 00000000..8ee14a10 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt @@ -0,0 +1,123 @@ +package com.dark.tool_neuron.repo + +import com.dark.tool_neuron.database.dao.McpServerDao +import com.dark.tool_neuron.models.table_schema.McpConnectionStatus +import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository for managing MCP (Model Context Protocol) server configurations + */ +@Singleton +class McpServerRepository @Inject constructor( + private val mcpServerDao: McpServerDao +) { + // Runtime connection status tracking (not persisted) + private val _connectionStatuses = MutableStateFlow>(emptyMap()) + val connectionStatuses: StateFlow> = _connectionStatuses.asStateFlow() + + /** + * Get all configured MCP servers + */ + fun getAllServers(): Flow> = mcpServerDao.getAllServers() + + /** + * Get only enabled MCP servers + */ + fun getEnabledServers(): Flow> = mcpServerDao.getEnabledServers() + + /** + * Get a specific server by ID + */ + suspend fun getServerById(id: String): McpServer? = mcpServerDao.getServerById(id) + + /** + * Add a new MCP server + */ + suspend fun addServer( + name: String, + url: String, + transportType: McpTransportType = McpTransportType.SSE, + apiKey: String? = null, + description: String = "" + ): McpServer { + val server = McpServer( + id = McpServer.generateId(), + name = name, + url = url.trim(), + transportType = transportType, + apiKey = apiKey?.trim()?.takeIf { it.isNotEmpty() }, + description = description.trim(), + isEnabled = true, + connectionStatus = McpConnectionStatus.DISCONNECTED, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + mcpServerDao.insertServer(server) + return server + } + + /** + * Update an existing MCP server + */ + suspend fun updateServer(server: McpServer) { + mcpServerDao.updateServer(server.copy(updatedAt = System.currentTimeMillis())) + } + + /** + * Delete an MCP server + */ + suspend fun deleteServer(id: String) { + mcpServerDao.deleteServerById(id) + // Remove from runtime status tracking + _connectionStatuses.value = _connectionStatuses.value - id + } + + /** + * Toggle server enabled/disabled state + */ + suspend fun setServerEnabled(id: String, enabled: Boolean) { + mcpServerDao.updateServerEnabled(id, enabled) + if (!enabled) { + // When disabled, set status to disconnected + updateConnectionStatus(id, McpConnectionStatus.DISCONNECTED) + } + } + + /** + * Update the runtime connection status of a server + */ + fun updateConnectionStatus(serverId: String, status: McpConnectionStatus, error: String? = null) { + _connectionStatuses.value = _connectionStatuses.value + (serverId to status) + } + + /** + * Update last connected timestamp + */ + suspend fun updateLastConnected(id: String) { + mcpServerDao.updateLastConnected(id, System.currentTimeMillis()) + } + + /** + * Get the count of all servers + */ + fun getServerCount(): Flow = mcpServerDao.getServerCount() + + /** + * Get the count of enabled servers + */ + fun getEnabledServerCount(): Flow = mcpServerDao.getEnabledServerCount() + + /** + * Get the current connection status for a server + */ + fun getConnectionStatus(serverId: String): McpConnectionStatus { + return _connectionStatuses.value[serverId] ?: McpConnectionStatus.DISCONNECTED + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt new file mode 100644 index 00000000..c9e04f26 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -0,0 +1,248 @@ +package com.dark.tool_neuron.service + +import android.util.Log +import com.dark.tool_neuron.models.table_schema.McpConnectionStatus +import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * MCP Client response data + */ +data class McpToolInfo( + val name: String, + val description: String?, + val inputSchema: String? +) + +data class McpTestResult( + val success: Boolean, + val message: String, + val tools: List = emptyList(), + val serverInfo: String? = null +) + +/** + * Client service for connecting to remote MCP (Model Context Protocol) servers. + * Supports SSE and Streamable HTTP transport types. + */ +@Singleton +class McpClientService @Inject constructor() { + + companion object { + private const val TAG = "McpClientService" + private const val CONNECT_TIMEOUT_SECONDS = 15L + private const val READ_TIMEOUT_SECONDS = 30L + private val JSON_MEDIA_TYPE = "application/json".toMediaType() + } + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + /** + * Test connection to an MCP server and retrieve server capabilities + */ + suspend fun testConnection(server: McpServer): McpTestResult = withContext(Dispatchers.IO) { + try { + Log.d(TAG, "Testing connection to MCP server: ${server.name} at ${server.url}") + + // Build the initialize request according to MCP protocol + val initializeRequest = JSONObject().apply { + put("jsonrpc", "2.0") + put("id", 1) + put("method", "initialize") + put("params", JSONObject().apply { + put("protocolVersion", "2024-11-05") + put("capabilities", JSONObject()) + put("clientInfo", JSONObject().apply { + put("name", "ToolNeuron") + put("version", "1.0.0") + }) + }) + } + + val requestBuilder = Request.Builder() + .url(server.url) + .post(initializeRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + + // Add API key if provided + server.apiKey?.let { key -> + requestBuilder.addHeader("Authorization", "Bearer $key") + } + + val response = httpClient.newCall(requestBuilder.build()).execute() + + if (!response.isSuccessful) { + return@withContext McpTestResult( + success = false, + message = "Server returned error: ${response.code} ${response.message}" + ) + } + + val responseBody = response.body?.string() + if (responseBody.isNullOrBlank()) { + return@withContext McpTestResult( + success = false, + message = "Server returned empty response" + ) + } + + val jsonResponse = JSONObject(responseBody) + + // Check for JSON-RPC error + if (jsonResponse.has("error")) { + val error = jsonResponse.getJSONObject("error") + return@withContext McpTestResult( + success = false, + message = "Server error: ${error.optString("message", "Unknown error")}" + ) + } + + // Parse the result + val result = jsonResponse.optJSONObject("result") + val serverInfo = result?.optJSONObject("serverInfo") + val serverName = serverInfo?.optString("name", "Unknown Server") ?: "Unknown Server" + val serverVersion = serverInfo?.optString("version", "") ?: "" + + // Now list available tools + val tools = listTools(server) + + McpTestResult( + success = true, + message = "Connected successfully", + tools = tools, + serverInfo = if (serverVersion.isNotEmpty()) "$serverName v$serverVersion" else serverName + ) + + } catch (e: Exception) { + Log.e(TAG, "Failed to connect to MCP server: ${e.message}", e) + McpTestResult( + success = false, + message = "Connection failed: ${e.message ?: "Unknown error"}" + ) + } + } + + /** + * List available tools from an MCP server + */ + private suspend fun listTools(server: McpServer): List = withContext(Dispatchers.IO) { + try { + val listToolsRequest = JSONObject().apply { + put("jsonrpc", "2.0") + put("id", 2) + put("method", "tools/list") + put("params", JSONObject()) + } + + val requestBuilder = Request.Builder() + .url(server.url) + .post(listToolsRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + + server.apiKey?.let { key -> + requestBuilder.addHeader("Authorization", "Bearer $key") + } + + val response = httpClient.newCall(requestBuilder.build()).execute() + + if (!response.isSuccessful) { + return@withContext emptyList() + } + + val responseBody = response.body?.string() ?: return@withContext emptyList() + val jsonResponse = JSONObject(responseBody) + + if (jsonResponse.has("error")) { + return@withContext emptyList() + } + + val result = jsonResponse.optJSONObject("result") ?: return@withContext emptyList() + val toolsArray = result.optJSONArray("tools") ?: return@withContext emptyList() + + val tools = mutableListOf() + for (i in 0 until toolsArray.length()) { + val tool = toolsArray.getJSONObject(i) + tools.add(McpToolInfo( + name = tool.getString("name"), + description = tool.optString("description", null), + inputSchema = tool.optJSONObject("inputSchema")?.toString() + )) + } + + tools + + } catch (e: Exception) { + Log.e(TAG, "Failed to list tools: ${e.message}", e) + emptyList() + } + } + + /** + * Call a tool on an MCP server + */ + suspend fun callTool( + server: McpServer, + toolName: String, + arguments: Map + ): Result = withContext(Dispatchers.IO) { + try { + val callToolRequest = JSONObject().apply { + put("jsonrpc", "2.0") + put("id", System.currentTimeMillis()) + put("method", "tools/call") + put("params", JSONObject().apply { + put("name", toolName) + put("arguments", JSONObject(arguments)) + }) + } + + val requestBuilder = Request.Builder() + .url(server.url) + .post(callToolRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + + server.apiKey?.let { key -> + requestBuilder.addHeader("Authorization", "Bearer $key") + } + + val response = httpClient.newCall(requestBuilder.build()).execute() + + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("Server returned: ${response.code}")) + } + + val responseBody = response.body?.string() + ?: return@withContext Result.failure(Exception("Empty response")) + + val jsonResponse = JSONObject(responseBody) + + if (jsonResponse.has("error")) { + val error = jsonResponse.getJSONObject("error") + return@withContext Result.failure(Exception(error.optString("message", "Unknown error"))) + } + + val result = jsonResponse.optJSONObject("result") + Result.success(result?.toString() ?: responseBody) + + } catch (e: Exception) { + Log.e(TAG, "Failed to call tool: ${e.message}", e) + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt new file mode 100644 index 00000000..9486c816 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt @@ -0,0 +1,831 @@ +package com.dark.tool_neuron.ui.screen + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dark.tool_neuron.models.table_schema.McpConnectionStatus +import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType +import com.dark.tool_neuron.service.McpTestResult +import com.dark.tool_neuron.ui.components.ActionButton +import com.dark.tool_neuron.ui.components.ActionTextButton +import com.dark.tool_neuron.ui.components.CuteSwitch +import com.dark.tool_neuron.ui.theme.rDp +import com.dark.tool_neuron.viewmodel.McpServerUiState +import com.dark.tool_neuron.viewmodel.McpServerViewModel +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun McpServersScreen( + onBackClick: () -> Unit, + viewModel: McpServerViewModel = hiltViewModel() +) { + val servers by viewModel.servers.collectAsStateWithLifecycle() + val serverCount by viewModel.serverCount.collectAsStateWithLifecycle() + val enabledServerCount by viewModel.enabledServerCount.collectAsStateWithLifecycle() + val showAddDialog by viewModel.showAddDialog.collectAsStateWithLifecycle() + val showEditDialog by viewModel.showEditDialog.collectAsStateWithLifecycle() + val selectedServer by viewModel.selectedServer.collectAsStateWithLifecycle() + val testingServerId by viewModel.testingServerId.collectAsStateWithLifecycle() + val testResult by viewModel.testResult.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "MCP Servers", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + "$enabledServerCount active / $serverCount total", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + ActionTextButton( + onClickListener = onBackClick, + icon = Icons.Default.ChevronLeft, + text = "Back", + modifier = Modifier.padding(start = rDp(6.dp)) + ) + }, + actions = { + ActionButton( + onClickListener = { viewModel.showAddServerDialog() }, + icon = Icons.Default.Add, + modifier = Modifier.padding(end = rDp(6.dp)) + ) + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (servers.isEmpty()) { + EmptyServersState(onAddServer = { viewModel.showAddServerDialog() }) + } else { + ServersList( + servers = servers, + testingServerId = testingServerId, + onServerClick = { viewModel.showEditServerDialog(it.server) }, + onToggleEnabled = { server, enabled -> + viewModel.toggleServerEnabled(server.server.id, enabled) + }, + onTestConnection = { viewModel.testConnection(it.server) }, + onDeleteServer = { viewModel.deleteServer(it.server.id) } + ) + } + + // Loading overlay + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + // Error snackbar + error?.let { errorMessage -> + Snackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(rDp(16.dp)), + action = { + TextButton(onClick = { viewModel.clearError() }) { + Text("Dismiss") + } + } + ) { + Text(errorMessage) + } + } + } + } + + // Add Server Dialog + if (showAddDialog) { + AddEditServerDialog( + server = null, + isTesting = testingServerId == "new", + testResult = testResult, + onDismiss = { viewModel.hideAddServerDialog() }, + onSave = { name, url, transportType, apiKey, description -> + viewModel.addServer(name, url, transportType, apiKey, description) + }, + onTestConnection = { name, url, transportType, apiKey -> + viewModel.testConnectionWithParams(name, url, transportType, apiKey) + }, + onClearTestResult = { viewModel.clearTestResult() } + ) + } + + // Edit Server Dialog + if (showEditDialog && selectedServer != null) { + AddEditServerDialog( + server = selectedServer, + isTesting = testingServerId == selectedServer?.id, + testResult = testResult, + onDismiss = { viewModel.hideEditServerDialog() }, + onSave = { name, url, transportType, apiKey, description -> + selectedServer?.let { server -> + viewModel.updateServer( + server.copy( + name = name, + url = url, + transportType = transportType, + apiKey = apiKey?.takeIf { it.isNotBlank() }, + description = description + ) + ) + } + }, + onTestConnection = { name, url, transportType, apiKey -> + viewModel.testConnectionWithParams(name, url, transportType, apiKey) + }, + onClearTestResult = { viewModel.clearTestResult() } + ) + } +} + +@Composable +private fun EmptyServersState(onAddServer: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(rDp(16.dp)) + ) { + Icon( + imageVector = Icons.Default.Cloud, + contentDescription = null, + modifier = Modifier.size(rDp(72.dp)), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + Text( + "No MCP Servers", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Connect to remote MCP servers to extend\nyour AI capabilities with external tools", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = rDp(32.dp)) + ) + Spacer(modifier = Modifier.height(rDp(8.dp))) + ActionTextButton( + onClickListener = onAddServer, + icon = Icons.Default.Add, + text = "Add Server", + shape = RoundedCornerShape(rDp(12.dp)) + ) + } + } +} + +@Composable +private fun ServersList( + servers: List, + testingServerId: String?, + onServerClick: (McpServerUiState) -> Unit, + onToggleEnabled: (McpServerUiState, Boolean) -> Unit, + onTestConnection: (McpServerUiState) -> Unit, + onDeleteServer: (McpServerUiState) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(rDp(16.dp)), + verticalArrangement = Arrangement.spacedBy(rDp(12.dp)) + ) { + // Info card + item { + InfoCard() + } + + items(servers, key = { it.server.id }) { serverState -> + ServerCard( + serverState = serverState, + isTesting = testingServerId == serverState.server.id, + onClick = { onServerClick(serverState) }, + onToggleEnabled = { enabled -> onToggleEnabled(serverState, enabled) }, + onTestConnection = { onTestConnection(serverState) }, + onDelete = { onDeleteServer(serverState) } + ) + } + } +} + +@Composable +private fun InfoCard() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(rDp(12.dp)), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) { + Row( + modifier = Modifier.padding(rDp(16.dp)), + horizontalArrangement = Arrangement.spacedBy(rDp(12.dp)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(rDp(24.dp)) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "MCP (Model Context Protocol)", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Connect to remote MCP servers to access external tools, resources, and capabilities for your AI conversations.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun ServerCard( + serverState: McpServerUiState, + isTesting: Boolean, + onClick: () -> Unit, + onToggleEnabled: (Boolean) -> Unit, + onTestConnection: () -> Unit, + onDelete: () -> Unit +) { + val server = serverState.server + val status = serverState.connectionStatus + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(rDp(16.dp)), + colors = CardDefaults.cardColors( + containerColor = when (status) { + McpConnectionStatus.CONNECTED -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + McpConnectionStatus.ERROR -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) + McpConnectionStatus.CONNECTING -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(rDp(16.dp)) + ) { + // Header row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + // Status indicator + StatusIndicator(status = status, isTesting = isTesting) + + Spacer(modifier = Modifier.width(rDp(12.dp))) + + Column { + Text( + text = server.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = server.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + CuteSwitch( + checked = server.isEnabled, + onCheckedChange = onToggleEnabled + ) + } + + // Transport type badge + Spacer(modifier = Modifier.height(rDp(12.dp))) + Row( + horizontalArrangement = Arrangement.spacedBy(rDp(8.dp)), + verticalAlignment = Alignment.CenterVertically + ) { + TransportBadge(transportType = server.transportType) + + if (server.apiKey != null) { + Badge( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.1f), + contentColor = MaterialTheme.colorScheme.secondary + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(rDp(4.dp)), + modifier = Modifier.padding(horizontal = rDp(4.dp)) + ) { + Icon( + Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(rDp(12.dp)) + ) + Text("Auth", style = MaterialTheme.typography.labelSmall) + } + } + } + + server.lastConnectedAt?.let { lastConnected -> + Text( + text = "Last connected: ${formatTimestamp(lastConnected)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + + // Description + if (server.description.isNotBlank()) { + Spacer(modifier = Modifier.height(rDp(8.dp))) + Text( + text = server.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + // Actions + Spacer(modifier = Modifier.height(rDp(12.dp))) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)) + Spacer(modifier = Modifier.height(rDp(12.dp))) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + ActionTextButton( + onClickListener = onTestConnection, + icon = Icons.Default.Refresh, + text = if (isTesting) "Testing..." else "Test Connection", + shape = RoundedCornerShape(rDp(12.dp)) + ) + + IconButton( + onClick = onDelete, + modifier = Modifier.size(rDp(36.dp)) + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(rDp(20.dp)) + ) + } + } + } + } +} + +@Composable +private fun StatusIndicator(status: McpConnectionStatus, isTesting: Boolean) { + val color = when { + isTesting -> MaterialTheme.colorScheme.tertiary + status == McpConnectionStatus.CONNECTED -> Color(0xFF4CAF50) // Green + status == McpConnectionStatus.ERROR -> MaterialTheme.colorScheme.error + status == McpConnectionStatus.CONNECTING -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + } + + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse + ), + label = "pulseAlpha" + ) + + Box( + modifier = Modifier + .size(rDp(12.dp)) + .clip(CircleShape) + .background( + if (isTesting || status == McpConnectionStatus.CONNECTING) { + color.copy(alpha = alpha) + } else { + color + } + ) + ) +} + +@Composable +private fun TransportBadge(transportType: McpTransportType) { + Badge( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + contentColor = MaterialTheme.colorScheme.primary + ) { + Text( + text = when (transportType) { + McpTransportType.SSE -> "SSE" + McpTransportType.STREAMABLE_HTTP -> "HTTP" + }, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = rDp(4.dp)) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddEditServerDialog( + server: McpServer?, + isTesting: Boolean, + testResult: McpTestResult?, + onDismiss: () -> Unit, + onSave: (name: String, url: String, transportType: McpTransportType, apiKey: String?, description: String) -> Unit, + onTestConnection: (name: String, url: String, transportType: McpTransportType, apiKey: String?) -> Unit, + onClearTestResult: () -> Unit +) { + var name by remember { mutableStateOf(server?.name ?: "") } + var url by remember { mutableStateOf(server?.url ?: "") } + var transportType by remember { mutableStateOf(server?.transportType ?: McpTransportType.SSE) } + var apiKey by remember { mutableStateOf(server?.apiKey ?: "") } + var description by remember { mutableStateOf(server?.description ?: "") } + var showApiKey by remember { mutableStateOf(false) } + + val isValid = name.isNotBlank() && url.isNotBlank() && + (url.startsWith("http://") || url.startsWith("https://")) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = MaterialTheme.colorScheme.surface, + dragHandle = { + Box( + Modifier + .padding(vertical = rDp(12.dp)) + .width(rDp(40.dp)) + .height(rDp(4.dp)) + .clip(RoundedCornerShape(rDp(2.dp))) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)) + ) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = rDp(24.dp)) + .padding(bottom = rDp(32.dp)) + ) { + // Header + Text( + text = if (server == null) "Add MCP Server" else "Edit MCP Server", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Text( + text = "Configure a remote MCP server connection", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(rDp(24.dp))) + + // Name field + OutlinedTextField( + value = name, + onValueChange = { + name = it + onClearTestResult() + }, + label = { Text("Server Name") }, + placeholder = { Text("My MCP Server") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(rDp(12.dp)), + leadingIcon = { + Icon(Icons.Default.Label, contentDescription = null) + } + ) + + Spacer(modifier = Modifier.height(rDp(16.dp))) + + // URL field + OutlinedTextField( + value = url, + onValueChange = { + url = it + onClearTestResult() + }, + label = { Text("Server URL") }, + placeholder = { Text("https://api.example.com/mcp") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(rDp(12.dp)), + leadingIcon = { + Icon(Icons.Default.Link, contentDescription = null) + }, + isError = url.isNotBlank() && !url.startsWith("http://") && !url.startsWith("https://"), + supportingText = if (url.isNotBlank() && !url.startsWith("http://") && !url.startsWith("https://")) { + { Text("URL must start with http:// or https://") } + } else null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) + ) + + Spacer(modifier = Modifier.height(rDp(16.dp))) + + // Transport type selector + Text( + text = "Transport Type", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(rDp(8.dp))) + Row( + horizontalArrangement = Arrangement.spacedBy(rDp(8.dp)) + ) { + FilterChip( + selected = transportType == McpTransportType.SSE, + onClick = { + transportType = McpTransportType.SSE + onClearTestResult() + }, + label = { Text("SSE (Server-Sent Events)") }, + leadingIcon = if (transportType == McpTransportType.SSE) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(rDp(16.dp))) } + } else null + ) + FilterChip( + selected = transportType == McpTransportType.STREAMABLE_HTTP, + onClick = { + transportType = McpTransportType.STREAMABLE_HTTP + onClearTestResult() + }, + label = { Text("Streamable HTTP") }, + leadingIcon = if (transportType == McpTransportType.STREAMABLE_HTTP) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(rDp(16.dp))) } + } else null + ) + } + + Spacer(modifier = Modifier.height(rDp(16.dp))) + + // API Key field + OutlinedTextField( + value = apiKey, + onValueChange = { + apiKey = it + onClearTestResult() + }, + label = { Text("API Key (Optional)") }, + placeholder = { Text("Bearer token or API key") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(rDp(12.dp)), + leadingIcon = { + Icon(Icons.Default.Key, contentDescription = null) + }, + trailingIcon = { + IconButton(onClick = { showApiKey = !showApiKey }) { + Icon( + if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showApiKey) "Hide" else "Show" + ) + } + }, + visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation() + ) + + Spacer(modifier = Modifier.height(rDp(16.dp))) + + // Description field + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description (Optional)") }, + placeholder = { Text("What this server provides...") }, + minLines = 2, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(rDp(12.dp)) + ) + + Spacer(modifier = Modifier.height(rDp(16.dp))) + + // Test result + AnimatedVisibility( + visible = testResult != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + testResult?.let { result -> + TestResultCard(result = result) + Spacer(modifier = Modifier.height(rDp(16.dp))) + } + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(rDp(12.dp)) + ) { + OutlinedButton( + onClick = { + onTestConnection(name, url, transportType, apiKey.takeIf { it.isNotBlank() }) + }, + enabled = isValid && !isTesting, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(rDp(12.dp)) + ) { + if (isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(rDp(16.dp)), + strokeWidth = rDp(2.dp) + ) + Spacer(modifier = Modifier.width(rDp(8.dp))) + } + Text(if (isTesting) "Testing..." else "Test Connection") + } + + Button( + onClick = { + onSave(name, url, transportType, apiKey.takeIf { it.isNotBlank() }, description) + }, + enabled = isValid, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(rDp(12.dp)) + ) { + Icon(Icons.Default.Save, contentDescription = null) + Spacer(modifier = Modifier.width(rDp(8.dp))) + Text(if (server == null) "Add Server" else "Save Changes") + } + } + } + } +} + +@Composable +private fun TestResultCard(result: McpTestResult) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(rDp(12.dp)), + color = if (result.success) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + } + ) { + Column( + modifier = Modifier.padding(rDp(16.dp)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(rDp(8.dp)) + ) { + Icon( + imageVector = if (result.success) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (result.success) { + Color(0xFF4CAF50) + } else { + MaterialTheme.colorScheme.error + }, + modifier = Modifier.size(rDp(20.dp)) + ) + Text( + text = if (result.success) "Connection Successful" else "Connection Failed", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = if (result.success) { + Color(0xFF4CAF50) + } else { + MaterialTheme.colorScheme.error + } + ) + } + + if (result.serverInfo != null) { + Spacer(modifier = Modifier.height(rDp(4.dp))) + Text( + text = "Server: ${result.serverInfo}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!result.success) { + Spacer(modifier = Modifier.height(rDp(4.dp))) + Text( + text = result.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + + if (result.tools.isNotEmpty()) { + Spacer(modifier = Modifier.height(rDp(8.dp))) + Text( + text = "Available Tools (${result.tools.size}):", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(rDp(4.dp))) + result.tools.take(5).forEach { tool -> + Text( + text = "• ${tool.name}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (result.tools.size > 5) { + Text( + text = "... and ${result.tools.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + } + } +} + +private fun formatTimestamp(timestamp: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + + return when { + diff < 60_000 -> "just now" + diff < 3600_000 -> "${diff / 60_000}m ago" + diff < 86400_000 -> "${diff / 3600_000}h ago" + diff < 604800_000 -> "${diff / 86400_000}d ago" + else -> { + val sdf = SimpleDateFormat("MMM dd", Locale.getDefault()) + sdf.format(Date(timestamp)) + } + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt index 6ee260bc..5f60dc6f 100644 --- a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -56,6 +57,7 @@ import java.util.Locale fun HomeDrawerScreen( onChatSelected: (String) -> Unit, onVaultManagerClick: () -> Unit, + onMcpServersClick: () -> Unit, viewModel: ChatListViewModel = hiltViewModel() ) { val chats by viewModel.chats.collectAsStateWithLifecycle() @@ -79,6 +81,7 @@ fun HomeDrawerScreen( topBar = { TopBar( onVaultManagerClick, + onMcpServersClick, onCreateNewChat = { viewModel.createNewChat { chatId -> onChatSelected(chatId) @@ -124,6 +127,7 @@ fun HomeDrawerScreen( @Composable private fun TopBar( onVaultManagerClick: () -> Unit, + onMcpServersClick: () -> Unit, onCreateNewChat: () -> Unit ) { TopAppBar( @@ -135,6 +139,11 @@ private fun TopBar( }, actions = { Row{ + ActionButton( + onClickListener = onMcpServersClick, + icon = Icons.Filled.Cloud, + modifier = Modifier.padding(end = rDp(6.dp)) + ) ActionButton( onClickListener = onVaultManagerClick, icon = R.drawable.smart_temp_message, diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeScreen.kt index 33f83d8c..a5f9a5ed 100644 --- a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeScreen.kt +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeScreen.kt @@ -89,6 +89,7 @@ fun HomeScreen( onStoreButtonClicked: () -> Unit, onModelEditor: () -> Unit, onVaultManagerClick: () -> Unit, + onMcpServersClick: () -> Unit, chatViewModel: ChatViewModel, llmModelViewModel: LLMModelViewModel ) { @@ -103,14 +104,23 @@ fun HomeScreen( ModalNavigationDrawer( drawerState = drawerState, drawerContent = { ModalDrawerSheet { - HomeDrawerScreen(onVaultManagerClick = { - onVaultManagerClick() - }, onChatSelected = { - chatViewModel.loadChat(it) - scope.launch { - drawerState.close() + HomeDrawerScreen( + onVaultManagerClick = { + onVaultManagerClick() + }, + onMcpServersClick = { + scope.launch { + drawerState.close() + } + onMcpServersClick() + }, + onChatSelected = { + chatViewModel.loadChat(it) + scope.launch { + drawerState.close() + } } - }) + ) } }) { Scaffold( diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt new file mode 100644 index 00000000..dcbfaaf8 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt @@ -0,0 +1,268 @@ +package com.dark.tool_neuron.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dark.tool_neuron.models.table_schema.McpConnectionStatus +import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType +import com.dark.tool_neuron.repo.McpServerRepository +import com.dark.tool_neuron.service.McpClientService +import com.dark.tool_neuron.service.McpTestResult +import com.dark.tool_neuron.service.McpToolInfo +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * UI state for a single MCP server with runtime status + */ +data class McpServerUiState( + val server: McpServer, + val connectionStatus: McpConnectionStatus = McpConnectionStatus.DISCONNECTED, + val isTesting: Boolean = false, + val lastTestResult: McpTestResult? = null +) + +/** + * ViewModel for managing MCP (Model Context Protocol) servers + */ +@HiltViewModel +class McpServerViewModel @Inject constructor( + private val repository: McpServerRepository, + private val mcpClientService: McpClientService +) : ViewModel() { + + // All servers with their runtime status + val servers: StateFlow> = combine( + repository.getAllServers(), + repository.connectionStatuses + ) { servers, statuses -> + servers.map { server -> + McpServerUiState( + server = server, + connectionStatus = statuses[server.id] ?: McpConnectionStatus.DISCONNECTED + ) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + // Server count + val serverCount: StateFlow = repository.getServerCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + // Enabled server count + val enabledServerCount: StateFlow = repository.getEnabledServerCount() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + // Currently selected server for editing + private val _selectedServer = MutableStateFlow(null) + val selectedServer: StateFlow = _selectedServer.asStateFlow() + + // Dialog state + private val _showAddDialog = MutableStateFlow(false) + val showAddDialog: StateFlow = _showAddDialog.asStateFlow() + + private val _showEditDialog = MutableStateFlow(false) + val showEditDialog: StateFlow = _showEditDialog.asStateFlow() + + // Test result for the current dialog + private val _testingServerId = MutableStateFlow(null) + val testingServerId: StateFlow = _testingServerId.asStateFlow() + + private val _testResult = MutableStateFlow(null) + val testResult: StateFlow = _testResult.asStateFlow() + + // Loading state + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + // Error state + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + /** + * Show the add server dialog + */ + fun showAddServerDialog() { + _selectedServer.value = null + _testResult.value = null + _showAddDialog.value = true + } + + /** + * Hide the add server dialog + */ + fun hideAddServerDialog() { + _showAddDialog.value = false + _testResult.value = null + } + + /** + * Show the edit server dialog + */ + fun showEditServerDialog(server: McpServer) { + _selectedServer.value = server + _testResult.value = null + _showEditDialog.value = true + } + + /** + * Hide the edit server dialog + */ + fun hideEditServerDialog() { + _showEditDialog.value = false + _selectedServer.value = null + _testResult.value = null + } + + /** + * Add a new MCP server + */ + fun addServer( + name: String, + url: String, + transportType: McpTransportType = McpTransportType.SSE, + apiKey: String? = null, + description: String = "" + ) { + viewModelScope.launch { + try { + _isLoading.value = true + repository.addServer(name, url, transportType, apiKey, description) + hideAddServerDialog() + } catch (e: Exception) { + _error.value = "Failed to add server: ${e.message}" + } finally { + _isLoading.value = false + } + } + } + + /** + * Update an existing MCP server + */ + fun updateServer(server: McpServer) { + viewModelScope.launch { + try { + _isLoading.value = true + repository.updateServer(server) + hideEditServerDialog() + } catch (e: Exception) { + _error.value = "Failed to update server: ${e.message}" + } finally { + _isLoading.value = false + } + } + } + + /** + * Delete an MCP server + */ + fun deleteServer(serverId: String) { + viewModelScope.launch { + try { + repository.deleteServer(serverId) + } catch (e: Exception) { + _error.value = "Failed to delete server: ${e.message}" + } + } + } + + /** + * Toggle server enabled state + */ + fun toggleServerEnabled(serverId: String, enabled: Boolean) { + viewModelScope.launch { + try { + repository.setServerEnabled(serverId, enabled) + } catch (e: Exception) { + _error.value = "Failed to update server: ${e.message}" + } + } + } + + /** + * Test connection to a server + */ + fun testConnection(server: McpServer) { + viewModelScope.launch { + try { + _testingServerId.value = server.id + _testResult.value = null + repository.updateConnectionStatus(server.id, McpConnectionStatus.CONNECTING) + + val result = mcpClientService.testConnection(server) + _testResult.value = result + + if (result.success) { + repository.updateConnectionStatus(server.id, McpConnectionStatus.CONNECTED) + repository.updateLastConnected(server.id) + } else { + repository.updateConnectionStatus(server.id, McpConnectionStatus.ERROR, result.message) + } + } catch (e: Exception) { + _testResult.value = McpTestResult( + success = false, + message = "Test failed: ${e.message}" + ) + repository.updateConnectionStatus(server.id, McpConnectionStatus.ERROR, e.message) + } finally { + _testingServerId.value = null + } + } + } + + /** + * Test connection with provided parameters (for add/edit dialog) + */ + fun testConnectionWithParams( + name: String, + url: String, + transportType: McpTransportType, + apiKey: String? + ) { + viewModelScope.launch { + try { + _testingServerId.value = "new" + _testResult.value = null + + val tempServer = McpServer( + id = "test", + name = name, + url = url, + transportType = transportType, + apiKey = apiKey?.takeIf { it.isNotBlank() } + ) + + val result = mcpClientService.testConnection(tempServer) + _testResult.value = result + } catch (e: Exception) { + _testResult.value = McpTestResult( + success = false, + message = "Test failed: ${e.message}" + ) + } finally { + _testingServerId.value = null + } + } + } + + /** + * Clear error message + */ + fun clearError() { + _error.value = null + } + + /** + * Clear test result + */ + fun clearTestResult() { + _testResult.value = null + } +} From f5370047ba3b8e99802698f1f7b44e2394e87794 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:51:26 +0000 Subject: [PATCH 03/27] Address code review feedback - fix hardcoded values and security improvements Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../dark/tool_neuron/database/AppDatabase.kt | 1 - .../models/converters/Converters.kt | 7 --- .../models/table_schema/McpServer.kt | 6 +-- .../tool_neuron/service/McpClientService.kt | 9 ++-- .../tool_neuron/ui/screen/McpServersScreen.kt | 46 ++++++++++++++++--- .../viewmodel/McpServerViewModel.kt | 4 +- 6 files changed, 49 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt b/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt index 4a0bfdbf..5490feb6 100644 --- a/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt +++ b/app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt @@ -128,7 +128,6 @@ abstract class AppDatabase : RoomDatabase() { transportType TEXT NOT NULL, apiKey TEXT, isEnabled INTEGER NOT NULL, - connectionStatus TEXT NOT NULL, lastError TEXT, createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, diff --git a/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt b/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt index dc0a5fba..33dd1528 100644 --- a/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt +++ b/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt @@ -3,7 +3,6 @@ package com.dark.tool_neuron.models.converters import androidx.room.TypeConverter import com.dark.tool_neuron.models.enums.PathType import com.dark.tool_neuron.models.enums.ProviderType -import com.dark.tool_neuron.models.table_schema.McpConnectionStatus import com.dark.tool_neuron.models.table_schema.McpTransportType class Converters { @@ -24,10 +23,4 @@ class Converters { @TypeConverter fun toMcpTransportType(value: String): McpTransportType = McpTransportType.valueOf(value) - - @TypeConverter - fun fromMcpConnectionStatus(value: McpConnectionStatus): String = value.name - - @TypeConverter - fun toMcpConnectionStatus(value: String): McpConnectionStatus = McpConnectionStatus.valueOf(value) } \ No newline at end of file diff --git a/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt index 40e7510b..46d9f206 100644 --- a/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt +++ b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt @@ -1,6 +1,7 @@ package com.dark.tool_neuron.models.table_schema import androidx.room.Entity +import androidx.room.Ignore import androidx.room.PrimaryKey /** @@ -12,7 +13,7 @@ enum class McpTransportType { } /** - * Connection status of an MCP server + * Connection status of an MCP server (runtime only, not persisted) */ enum class McpConnectionStatus { DISCONNECTED, @@ -45,9 +46,6 @@ data class McpServer( /** Whether the server is enabled */ val isEnabled: Boolean = true, - /** Current connection status (not persisted, managed at runtime) */ - val connectionStatus: McpConnectionStatus = McpConnectionStatus.DISCONNECTED, - /** Last error message if connection failed */ val lastError: String? = null, diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index c9e04f26..6692314d 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -42,6 +42,9 @@ class McpClientService @Inject constructor() { private const val TAG = "McpClientService" private const val CONNECT_TIMEOUT_SECONDS = 15L private const val READ_TIMEOUT_SECONDS = 30L + private const val MCP_PROTOCOL_VERSION = "2024-11-05" + private const val CLIENT_NAME = "ToolNeuron" + private const val CLIENT_VERSION = "1.0.0" private val JSON_MEDIA_TYPE = "application/json".toMediaType() } @@ -63,11 +66,11 @@ class McpClientService @Inject constructor() { put("id", 1) put("method", "initialize") put("params", JSONObject().apply { - put("protocolVersion", "2024-11-05") + put("protocolVersion", MCP_PROTOCOL_VERSION) put("capabilities", JSONObject()) put("clientInfo", JSONObject().apply { - put("name", "ToolNeuron") - put("version", "1.0.0") + put("name", CLIENT_NAME) + put("version", CLIENT_VERSION) }) }) } diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt index 9486c816..877efafe 100644 --- a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt @@ -39,6 +39,9 @@ import com.dark.tool_neuron.viewmodel.McpServerViewModel import java.text.SimpleDateFormat import java.util.* +// Success color for connected/successful states +private val SuccessGreen = Color(0xFF4CAF50) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun McpServersScreen( @@ -451,7 +454,7 @@ private fun ServerCard( private fun StatusIndicator(status: McpConnectionStatus, isTesting: Boolean) { val color = when { isTesting -> MaterialTheme.colorScheme.tertiary - status == McpConnectionStatus.CONNECTED -> Color(0xFF4CAF50) // Green + status == McpConnectionStatus.CONNECTED -> SuccessGreen status == McpConnectionStatus.ERROR -> MaterialTheme.colorScheme.error status == McpConnectionStatus.CONNECTING -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) @@ -576,6 +579,9 @@ private fun AddEditServerDialog( Spacer(modifier = Modifier.height(rDp(16.dp))) // URL field + val isInsecureUrl = url.startsWith("http://") && !url.startsWith("https://") + val showSecurityWarning = isInsecureUrl && apiKey.isNotBlank() + OutlinedTextField( value = url, onValueChange = { @@ -590,10 +596,38 @@ private fun AddEditServerDialog( leadingIcon = { Icon(Icons.Default.Link, contentDescription = null) }, - isError = url.isNotBlank() && !url.startsWith("http://") && !url.startsWith("https://"), - supportingText = if (url.isNotBlank() && !url.startsWith("http://") && !url.startsWith("https://")) { - { Text("URL must start with http:// or https://") } + trailingIcon = if (showSecurityWarning) { + { + Icon( + Icons.Default.Warning, + contentDescription = "Security warning", + tint = MaterialTheme.colorScheme.error + ) + } } else null, + isError = url.isNotBlank() && !url.startsWith("http://") && !url.startsWith("https://"), + supportingText = when { + url.isNotBlank() && !url.startsWith("http://") && !url.startsWith("https://") -> { + { Text("URL must start with http:// or https://") } + } + showSecurityWarning -> { + { + Text( + "Warning: Using HTTP with an API key is insecure. Use HTTPS for secure connections.", + color = MaterialTheme.colorScheme.error + ) + } + } + isInsecureUrl -> { + { + Text( + "Consider using HTTPS for secure connections", + color = MaterialTheme.colorScheme.tertiary + ) + } + } + else -> null + }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) ) @@ -751,7 +785,7 @@ private fun TestResultCard(result: McpTestResult) { imageVector = if (result.success) Icons.Default.CheckCircle else Icons.Default.Error, contentDescription = null, tint = if (result.success) { - Color(0xFF4CAF50) + SuccessGreen } else { MaterialTheme.colorScheme.error }, @@ -762,7 +796,7 @@ private fun TestResultCard(result: McpTestResult) { style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = if (result.success) { - Color(0xFF4CAF50) + SuccessGreen } else { MaterialTheme.colorScheme.error } diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt index dcbfaaf8..49051c1f 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt @@ -24,9 +24,7 @@ import javax.inject.Inject */ data class McpServerUiState( val server: McpServer, - val connectionStatus: McpConnectionStatus = McpConnectionStatus.DISCONNECTED, - val isTesting: Boolean = false, - val lastTestResult: McpTestResult? = null + val connectionStatus: McpConnectionStatus = McpConnectionStatus.DISCONNECTED ) /** From 2281975e7a6ab6ec2a3b52b7b65fa2b6f72d57ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:53:54 +0000 Subject: [PATCH 04/27] Fix DAO default parameters and explicit timestamp passing Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../java/com/dark/tool_neuron/database/dao/McpServerDao.kt | 4 ++-- .../com/dark/tool_neuron/models/table_schema/McpServer.kt | 1 - .../java/com/dark/tool_neuron/repo/McpServerRepository.kt | 7 ++++--- .../com/dark/tool_neuron/viewmodel/McpServerViewModel.kt | 1 - 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt b/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt index d06d3e66..5525881d 100644 --- a/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt +++ b/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt @@ -29,10 +29,10 @@ interface McpServerDao { suspend fun deleteServerById(id: String) @Query("UPDATE mcp_servers SET isEnabled = :isEnabled, updatedAt = :updatedAt WHERE id = :id") - suspend fun updateServerEnabled(id: String, isEnabled: Boolean, updatedAt: Long = System.currentTimeMillis()) + suspend fun updateServerEnabled(id: String, isEnabled: Boolean, updatedAt: Long) @Query("UPDATE mcp_servers SET lastConnectedAt = :timestamp, updatedAt = :updatedAt WHERE id = :id") - suspend fun updateLastConnected(id: String, timestamp: Long, updatedAt: Long = System.currentTimeMillis()) + suspend fun updateLastConnected(id: String, timestamp: Long, updatedAt: Long) @Query("SELECT COUNT(*) FROM mcp_servers") fun getServerCount(): Flow diff --git a/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt index 46d9f206..a8087cd4 100644 --- a/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt +++ b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt @@ -1,7 +1,6 @@ package com.dark.tool_neuron.models.table_schema import androidx.room.Entity -import androidx.room.Ignore import androidx.room.PrimaryKey /** diff --git a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt index 8ee14a10..72737d0b 100644 --- a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt +++ b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt @@ -72,7 +72,7 @@ class McpServerRepository @Inject constructor( /** * Delete an MCP server - */ + */ suspend fun deleteServer(id: String) { mcpServerDao.deleteServerById(id) // Remove from runtime status tracking @@ -83,7 +83,7 @@ class McpServerRepository @Inject constructor( * Toggle server enabled/disabled state */ suspend fun setServerEnabled(id: String, enabled: Boolean) { - mcpServerDao.updateServerEnabled(id, enabled) + mcpServerDao.updateServerEnabled(id, enabled, System.currentTimeMillis()) if (!enabled) { // When disabled, set status to disconnected updateConnectionStatus(id, McpConnectionStatus.DISCONNECTED) @@ -101,7 +101,8 @@ class McpServerRepository @Inject constructor( * Update last connected timestamp */ suspend fun updateLastConnected(id: String) { - mcpServerDao.updateLastConnected(id, System.currentTimeMillis()) + val now = System.currentTimeMillis() + mcpServerDao.updateLastConnected(id, now, now) } /** diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt index 49051c1f..f47e9570 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt @@ -8,7 +8,6 @@ import com.dark.tool_neuron.models.table_schema.McpTransportType import com.dark.tool_neuron.repo.McpServerRepository import com.dark.tool_neuron.service.McpClientService import com.dark.tool_neuron.service.McpTestResult -import com.dark.tool_neuron.service.McpToolInfo import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted From f0651a25310b0ab9de9a56afd3310e6c7a1aeb09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:23:10 +0000 Subject: [PATCH 05/27] Fix MCP SSE support for Zapier MCP servers and build errors Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/repo/McpServerRepository.kt | 1 - .../tool_neuron/service/McpClientService.kt | 41 +++++++++++++++---- .../ui/screen/home_screen/HomeDrawerScreen.kt | 4 +- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt index 72737d0b..e8a1295e 100644 --- a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt +++ b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt @@ -55,7 +55,6 @@ class McpServerRepository @Inject constructor( apiKey = apiKey?.trim()?.takeIf { it.isNotEmpty() }, description = description.trim(), isEnabled = true, - connectionStatus = McpConnectionStatus.DISCONNECTED, createdAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis() ) diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 6692314d..49377fc9 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -1,9 +1,7 @@ package com.dark.tool_neuron.service import android.util.Log -import com.dark.tool_neuron.models.table_schema.McpConnectionStatus import com.dark.tool_neuron.models.table_schema.McpServer -import com.dark.tool_neuron.models.table_schema.McpTransportType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -46,6 +44,8 @@ class McpClientService @Inject constructor() { private const val CLIENT_NAME = "ToolNeuron" private const val CLIENT_VERSION = "1.0.0" private val JSON_MEDIA_TYPE = "application/json".toMediaType() + // Accept header must include both JSON and SSE for MCP servers like Zapier + private const val ACCEPT_HEADER = "application/json, text/event-stream" } private val httpClient = OkHttpClient.Builder() @@ -53,6 +53,23 @@ class McpClientService @Inject constructor() { .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() + /** + * Parse SSE (Server-Sent Events) response format. + * SSE responses come as "event: message\ndata: {...json...}" + */ + private fun parseSseResponse(responseBody: String): String { + val lines = responseBody.lines() + val dataLines = lines.filter { it.startsWith("data:") } + + return if (dataLines.isNotEmpty()) { + // Extract JSON from "data: {...}" format + dataLines.joinToString("\n") { it.removePrefix("data:").trim() } + } else { + // Not SSE format, return as-is + responseBody + } + } + /** * Test connection to an MCP server and retrieve server capabilities */ @@ -79,7 +96,7 @@ class McpClientService @Inject constructor() { .url(server.url) .post(initializeRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) .addHeader("Content-Type", "application/json") - .addHeader("Accept", "application/json") + .addHeader("Accept", ACCEPT_HEADER) // Add API key if provided server.apiKey?.let { key -> @@ -95,14 +112,16 @@ class McpClientService @Inject constructor() { ) } - val responseBody = response.body?.string() - if (responseBody.isNullOrBlank()) { + val rawResponseBody = response.body?.string() + if (rawResponseBody.isNullOrBlank()) { return@withContext McpTestResult( success = false, message = "Server returned empty response" ) } + // Parse SSE format if needed + val responseBody = parseSseResponse(rawResponseBody) val jsonResponse = JSONObject(responseBody) // Check for JSON-RPC error @@ -155,7 +174,7 @@ class McpClientService @Inject constructor() { .url(server.url) .post(listToolsRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) .addHeader("Content-Type", "application/json") - .addHeader("Accept", "application/json") + .addHeader("Accept", ACCEPT_HEADER) server.apiKey?.let { key -> requestBuilder.addHeader("Authorization", "Bearer $key") @@ -167,7 +186,9 @@ class McpClientService @Inject constructor() { return@withContext emptyList() } - val responseBody = response.body?.string() ?: return@withContext emptyList() + val rawResponseBody = response.body?.string() ?: return@withContext emptyList() + // Parse SSE format if needed + val responseBody = parseSseResponse(rawResponseBody) val jsonResponse = JSONObject(responseBody) if (jsonResponse.has("error")) { @@ -218,7 +239,7 @@ class McpClientService @Inject constructor() { .url(server.url) .post(callToolRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) .addHeader("Content-Type", "application/json") - .addHeader("Accept", "application/json") + .addHeader("Accept", ACCEPT_HEADER) server.apiKey?.let { key -> requestBuilder.addHeader("Authorization", "Bearer $key") @@ -230,9 +251,11 @@ class McpClientService @Inject constructor() { return@withContext Result.failure(Exception("Server returned: ${response.code}")) } - val responseBody = response.body?.string() + val rawResponseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) + // Parse SSE format if needed + val responseBody = parseSseResponse(rawResponseBody) val jsonResponse = JSONObject(responseBody) if (jsonResponse.has("error")) { diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt index 5f60dc6f..4e7748a8 100644 --- a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt @@ -79,7 +79,7 @@ fun HomeDrawerScreen( ), containerColor = MaterialTheme.colorScheme.background, topBar = { - TopBar( + DrawerTopBar( onVaultManagerClick, onMcpServersClick, onCreateNewChat = { @@ -125,7 +125,7 @@ fun HomeDrawerScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TopBar( +private fun DrawerTopBar( onVaultManagerClick: () -> Unit, onMcpServersClick: () -> Unit, onCreateNewChat: () -> Unit From 49966180ed233f1c9e5207d7e9a66cad5a1ac159 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:24:42 +0000 Subject: [PATCH 06/27] Improve SSE parsing to correctly handle event format per SSE spec Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/service/McpClientService.kt | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 49377fc9..d57a41d5 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -55,19 +55,33 @@ class McpClientService @Inject constructor() { /** * Parse SSE (Server-Sent Events) response format. - * SSE responses come as "event: message\ndata: {...json...}" + * SSE responses come as "event: message\ndata: {...json...}\n\n" + * This handles single-event responses commonly used in MCP request/response patterns. */ private fun parseSseResponse(responseBody: String): String { - val lines = responseBody.lines() - val dataLines = lines.filter { it.startsWith("data:") } - - return if (dataLines.isNotEmpty()) { - // Extract JSON from "data: {...}" format - dataLines.joinToString("\n") { it.removePrefix("data:").trim() } - } else { + // Check if this is an SSE response + if (!responseBody.contains("data:")) { // Not SSE format, return as-is - responseBody + return responseBody } + + // Split by double newlines to separate events + val events = responseBody.split("\n\n") + + // Find the last event with data (for request/response pattern) + for (event in events.reversed()) { + val lines = event.lines() + val dataLines = lines.filter { it.startsWith("data:") } + + if (dataLines.isNotEmpty()) { + // Extract JSON from "data: {...}" format + // Multiple data lines in same event should be joined with newlines per SSE spec + return dataLines.joinToString("\n") { it.removePrefix("data:").trim() } + } + } + + // Fallback: return original response + return responseBody } /** From df94f6e5fe0776626bcfd4136a3c67de5e9b5a15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:41:53 +0000 Subject: [PATCH 07/27] Add proper Streamable HTTP transport support alongside SSE Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/service/McpClientService.kt | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index d57a41d5..2e9412ef 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -2,6 +2,7 @@ package com.dark.tool_neuron.service import android.util.Log import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -31,7 +32,11 @@ data class McpTestResult( /** * Client service for connecting to remote MCP (Model Context Protocol) servers. - * Supports SSE and Streamable HTTP transport types. + * Supports both SSE (Server-Sent Events) and Streamable HTTP transport types. + * + * Transport Types: + * - SSE: Uses text/event-stream for responses (legacy, being deprecated) + * - Streamable HTTP: Uses standard JSON responses (recommended) */ @Singleton class McpClientService @Inject constructor() { @@ -44,8 +49,9 @@ class McpClientService @Inject constructor() { private const val CLIENT_NAME = "ToolNeuron" private const val CLIENT_VERSION = "1.0.0" private val JSON_MEDIA_TYPE = "application/json".toMediaType() - // Accept header must include both JSON and SSE for MCP servers like Zapier - private const val ACCEPT_HEADER = "application/json, text/event-stream" + // Accept headers for different transport types + private const val ACCEPT_HEADER_SSE = "application/json, text/event-stream" + private const val ACCEPT_HEADER_HTTP = "application/json" } private val httpClient = OkHttpClient.Builder() @@ -53,6 +59,26 @@ class McpClientService @Inject constructor() { .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() + /** + * Get the appropriate Accept header based on transport type + */ + private fun getAcceptHeader(transportType: McpTransportType): String { + return when (transportType) { + McpTransportType.SSE -> ACCEPT_HEADER_SSE + McpTransportType.STREAMABLE_HTTP -> ACCEPT_HEADER_HTTP + } + } + + /** + * Parse response body, handling SSE format for SSE transport + */ + private fun parseResponse(responseBody: String, transportType: McpTransportType): String { + return when (transportType) { + McpTransportType.SSE -> parseSseResponse(responseBody) + McpTransportType.STREAMABLE_HTTP -> responseBody // Standard JSON, no parsing needed + } + } + /** * Parse SSE (Server-Sent Events) response format. * SSE responses come as "event: message\ndata: {...json...}\n\n" @@ -89,7 +115,7 @@ class McpClientService @Inject constructor() { */ suspend fun testConnection(server: McpServer): McpTestResult = withContext(Dispatchers.IO) { try { - Log.d(TAG, "Testing connection to MCP server: ${server.name} at ${server.url}") + Log.d(TAG, "Testing connection to MCP server: ${server.name} at ${server.url} (transport: ${server.transportType})") // Build the initialize request according to MCP protocol val initializeRequest = JSONObject().apply { @@ -110,7 +136,7 @@ class McpClientService @Inject constructor() { .url(server.url) .post(initializeRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) .addHeader("Content-Type", "application/json") - .addHeader("Accept", ACCEPT_HEADER) + .addHeader("Accept", getAcceptHeader(server.transportType)) // Add API key if provided server.apiKey?.let { key -> @@ -134,8 +160,8 @@ class McpClientService @Inject constructor() { ) } - // Parse SSE format if needed - val responseBody = parseSseResponse(rawResponseBody) + // Parse response based on transport type + val responseBody = parseResponse(rawResponseBody, server.transportType) val jsonResponse = JSONObject(responseBody) // Check for JSON-RPC error @@ -188,7 +214,7 @@ class McpClientService @Inject constructor() { .url(server.url) .post(listToolsRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) .addHeader("Content-Type", "application/json") - .addHeader("Accept", ACCEPT_HEADER) + .addHeader("Accept", getAcceptHeader(server.transportType)) server.apiKey?.let { key -> requestBuilder.addHeader("Authorization", "Bearer $key") @@ -201,8 +227,8 @@ class McpClientService @Inject constructor() { } val rawResponseBody = response.body?.string() ?: return@withContext emptyList() - // Parse SSE format if needed - val responseBody = parseSseResponse(rawResponseBody) + // Parse response based on transport type + val responseBody = parseResponse(rawResponseBody, server.transportType) val jsonResponse = JSONObject(responseBody) if (jsonResponse.has("error")) { @@ -253,7 +279,7 @@ class McpClientService @Inject constructor() { .url(server.url) .post(callToolRequest.toString().toRequestBody(JSON_MEDIA_TYPE)) .addHeader("Content-Type", "application/json") - .addHeader("Accept", ACCEPT_HEADER) + .addHeader("Accept", getAcceptHeader(server.transportType)) server.apiKey?.let { key -> requestBuilder.addHeader("Authorization", "Bearer $key") @@ -268,8 +294,8 @@ class McpClientService @Inject constructor() { val rawResponseBody = response.body?.string() ?: return@withContext Result.failure(Exception("Empty response")) - // Parse SSE format if needed - val responseBody = parseSseResponse(rawResponseBody) + // Parse response based on transport type + val responseBody = parseResponse(rawResponseBody, server.transportType) val jsonResponse = JSONObject(responseBody) if (jsonResponse.has("error")) { From a360b6b4bbbd1bc8d32a1635fcc78e8f4b5353b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:43:26 +0000 Subject: [PATCH 08/27] Fix code review comments on transport documentation Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../com/dark/tool_neuron/service/McpClientService.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 2e9412ef..87cd91e8 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -35,8 +35,8 @@ data class McpTestResult( * Supports both SSE (Server-Sent Events) and Streamable HTTP transport types. * * Transport Types: - * - SSE: Uses text/event-stream for responses (legacy, being deprecated) - * - Streamable HTTP: Uses standard JSON responses (recommended) + * - SSE: Uses text/event-stream for responses (commonly used by servers like Zapier MCP) + * - Streamable HTTP: Uses standard JSON responses */ @Singleton class McpClientService @Inject constructor() { @@ -70,12 +70,13 @@ class McpClientService @Inject constructor() { } /** - * Parse response body, handling SSE format for SSE transport + * Parse response body, handling SSE format for SSE transport. + * For Streamable HTTP, returns the raw JSON body (no SSE envelope to parse). */ private fun parseResponse(responseBody: String, transportType: McpTransportType): String { return when (transportType) { McpTransportType.SSE -> parseSseResponse(responseBody) - McpTransportType.STREAMABLE_HTTP -> responseBody // Standard JSON, no parsing needed + McpTransportType.STREAMABLE_HTTP -> responseBody // Already JSON, no SSE envelope to parse } } From 669318bde6bcb480a109bde8cfe2e5dbad7e5630 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:28:44 +0000 Subject: [PATCH 09/27] Address code review feedback and add GitHub workflow for debug APK Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .github/workflows/build-debug-apk.yml | 35 +++++++++++++++ .../tool_neuron/repo/McpServerRepository.kt | 32 +++++++++++++- .../tool_neuron/service/McpClientService.kt | 43 ++++++++++++++++++- .../ui/screen/home_screen/HomeDrawerScreen.kt | 9 ++++ .../viewmodel/McpServerViewModel.kt | 27 ++++++++++-- 5 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/build-debug-apk.yml diff --git a/.github/workflows/build-debug-apk.yml b/.github/workflows/build-debug-apk.yml new file mode 100644 index 00000000..e45db48f --- /dev/null +++ b/.github/workflows/build-debug-apk.yml @@ -0,0 +1,35 @@ +name: Build Debug APK + +on: + pull_request: + branches: [ main, master ] + workflow_dispatch: + +jobs: + build: + name: Build Debug APK + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build Debug APK + run: ./gradlew assembleDebug --no-daemon + + - name: Upload Debug APK + uses: actions/upload-artifact@v4 + with: + name: app-debug + path: app/build/outputs/apk/debug/app-debug.apk + retention-days: 14 diff --git a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt index e8a1295e..bb8c5726 100644 --- a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt +++ b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt @@ -1,5 +1,6 @@ package com.dark.tool_neuron.repo +import android.util.Log import com.dark.tool_neuron.database.dao.McpServerDao import com.dark.tool_neuron.models.table_schema.McpConnectionStatus import com.dark.tool_neuron.models.table_schema.McpServer @@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.net.URI import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +20,9 @@ import javax.inject.Singleton class McpServerRepository @Inject constructor( private val mcpServerDao: McpServerDao ) { + companion object { + private const val TAG = "McpServerRepository" + } // Runtime connection status tracking (not persisted) private val _connectionStatuses = MutableStateFlow>(emptyMap()) val connectionStatuses: StateFlow> = _connectionStatuses.asStateFlow() @@ -39,6 +44,7 @@ class McpServerRepository @Inject constructor( /** * Add a new MCP server + * @throws IllegalArgumentException if the URL is not valid */ suspend fun addServer( name: String, @@ -47,10 +53,28 @@ class McpServerRepository @Inject constructor( apiKey: String? = null, description: String = "" ): McpServer { + val trimmedUrl = url.trim() + + // Validate URL format + val validatedUrl = try { + val uri = URI(trimmedUrl) + if (uri.scheme.isNullOrBlank() || uri.host.isNullOrBlank()) { + throw IllegalArgumentException("Invalid server URL: missing scheme or host") + } + if (uri.scheme != "http" && uri.scheme != "https") { + throw IllegalArgumentException("Invalid server URL scheme: ${uri.scheme}") + } + trimmedUrl + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + throw IllegalArgumentException("Invalid server URL format: '$trimmedUrl'", e) + } + val server = McpServer( id = McpServer.generateId(), name = name, - url = url.trim(), + url = validatedUrl, transportType = transportType, apiKey = apiKey?.trim()?.takeIf { it.isNotEmpty() }, description = description.trim(), @@ -91,8 +115,14 @@ class McpServerRepository @Inject constructor( /** * Update the runtime connection status of a server + * @param serverId The ID of the server + * @param status The new connection status + * @param error Optional error message when status is ERROR */ fun updateConnectionStatus(serverId: String, status: McpConnectionStatus, error: String? = null) { + if (error != null && status == McpConnectionStatus.ERROR) { + Log.w(TAG, "MCP server $serverId connection error: $error") + } _connectionStatuses.value = _connectionStatuses.value + (serverId to status) } diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 87cd91e8..63b87140 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -59,6 +59,23 @@ class McpClientService @Inject constructor() { .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() + /** + * Clean up resources associated with the underlying OkHttpClient. + * This should be called when the McpClientService is no longer needed. + */ + fun close() { + try { + // Shut down the executor service used by the dispatcher + httpClient.dispatcher.executorService.shutdown() + // Evict all connections from the connection pool + httpClient.connectionPool.evictAll() + // Close any configured cache + httpClient.cache?.close() + } catch (e: Exception) { + Log.w(TAG, "Error while closing OkHttpClient resources", e) + } + } + /** * Get the appropriate Accept header based on transport type */ @@ -84,6 +101,9 @@ class McpClientService @Inject constructor() { * Parse SSE (Server-Sent Events) response format. * SSE responses come as "event: message\ndata: {...json...}\n\n" * This handles single-event responses commonly used in MCP request/response patterns. + * + * Note: For streaming scenarios, this parser extracts the last complete event. + * In MCP's request/response pattern, this is typically the only event. */ private fun parseSseResponse(responseBody: String): String { // Check if this is an SSE response @@ -103,7 +123,16 @@ class McpClientService @Inject constructor() { if (dataLines.isNotEmpty()) { // Extract JSON from "data: {...}" format // Multiple data lines in same event should be joined with newlines per SSE spec - return dataLines.joinToString("\n") { it.removePrefix("data:").trim() } + val joinedData = dataLines.joinToString("\n") { it.removePrefix("data:").trim() } + + // Validate that the joined data is valid JSON to avoid propagating malformed JSON-RPC + return try { + JSONObject(joinedData) + joinedData + } catch (e: Exception) { + Log.w(TAG, "SSE data is not valid JSON; returning raw SSE response body", e) + responseBody + } } } @@ -163,7 +192,17 @@ class McpClientService @Inject constructor() { // Parse response based on transport type val responseBody = parseResponse(rawResponseBody, server.transportType) - val jsonResponse = JSONObject(responseBody) + + // Parse JSON response with specific error handling + val jsonResponse = try { + JSONObject(responseBody) + } catch (e: org.json.JSONException) { + Log.e(TAG, "Failed to parse MCP response as JSON: ${e.message}") + return@withContext McpTestResult( + success = false, + message = "Server returned invalid JSON response. The server may not be a valid MCP server." + ) + } // Check for JSON-RPC error if (jsonResponse.has("error")) { diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt index 4e7748a8..baacd1f2 100644 --- a/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/home_screen/HomeDrawerScreen.kt @@ -123,6 +123,15 @@ fun HomeDrawerScreen( } } +/** + * Top app bar used in the home drawer screen. + * Provides quick access actions for managing vaults, configuring MCP servers, + * and creating a new chat session. + * + * @param onVaultManagerClick Invoked when the vault manager action is selected. + * @param onMcpServersClick Invoked when the MCP servers action is selected. + * @param onCreateNewChat Invoked when the user requests to create a new chat. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DrawerTopBar( diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt index f47e9570..eab5e535 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt @@ -9,6 +9,7 @@ import com.dark.tool_neuron.repo.McpServerRepository import com.dark.tool_neuron.service.McpClientService import com.dark.tool_neuron.service.McpTestResult import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -35,6 +36,10 @@ class McpServerViewModel @Inject constructor( private val mcpClientService: McpClientService ) : ViewModel() { + companion object { + private const val ERROR_DISPLAY_DURATION_MS = 5000L + } + // All servers with their runtime status val servers: StateFlow> = combine( repository.getAllServers(), @@ -82,6 +87,20 @@ class McpServerViewModel @Inject constructor( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + /** + * Set an error message that auto-clears after a timeout + */ + private fun setError(message: String) { + _error.value = message + viewModelScope.launch { + delay(ERROR_DISPLAY_DURATION_MS) + // Only clear if it's still the same error + if (_error.value == message) { + _error.value = null + } + } + } + /** * Show the add server dialog */ @@ -133,7 +152,7 @@ class McpServerViewModel @Inject constructor( repository.addServer(name, url, transportType, apiKey, description) hideAddServerDialog() } catch (e: Exception) { - _error.value = "Failed to add server: ${e.message}" + setError("Failed to add server: ${e.message}") } finally { _isLoading.value = false } @@ -150,7 +169,7 @@ class McpServerViewModel @Inject constructor( repository.updateServer(server) hideEditServerDialog() } catch (e: Exception) { - _error.value = "Failed to update server: ${e.message}" + setError("Failed to update server: ${e.message}") } finally { _isLoading.value = false } @@ -165,7 +184,7 @@ class McpServerViewModel @Inject constructor( try { repository.deleteServer(serverId) } catch (e: Exception) { - _error.value = "Failed to delete server: ${e.message}" + setError("Failed to delete server: ${e.message}") } } } @@ -178,7 +197,7 @@ class McpServerViewModel @Inject constructor( try { repository.setServerEnabled(serverId, enabled) } catch (e: Exception) { - _error.value = "Failed to update server: ${e.message}" + setError("Failed to update server: ${e.message}") } } } From 34b3dba8b8f19732249ef4b47345babe4af1f63c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:30:02 +0000 Subject: [PATCH 10/27] Fix race condition in error auto-clearing Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/viewmodel/McpServerViewModel.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt index eab5e535..64688ef3 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpServerViewModel.kt @@ -86,18 +86,22 @@ class McpServerViewModel @Inject constructor( // Error state private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + private var errorClearJob: kotlinx.coroutines.Job? = null /** - * Set an error message that auto-clears after a timeout + * Set an error message that auto-clears after a timeout. + * Cancels any previous auto-clear job to prevent race conditions. */ private fun setError(message: String) { + // Cancel any pending error clear job + errorClearJob?.cancel() + _error.value = message - viewModelScope.launch { + + // Start new clear job + errorClearJob = viewModelScope.launch { delay(ERROR_DISPLAY_DURATION_MS) - // Only clear if it's still the same error - if (_error.value == message) { - _error.value = null - } + _error.value = null } } From dd31ae5cf9b9eb98d2255324c6d5d5dbe44e4b21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:39:31 +0000 Subject: [PATCH 11/27] Initial plan From da0af0ccafa543122870c34c9ac6bf3d9a9a2ea1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:54:29 +0000 Subject: [PATCH 12/27] Enable BuildConfig for memory-vault Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- memory-vault/build.gradle.kts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/memory-vault/build.gradle.kts b/memory-vault/build.gradle.kts index 3dd1a1cd..1bec47f5 100644 --- a/memory-vault/build.gradle.kts +++ b/memory-vault/build.gradle.kts @@ -27,6 +27,10 @@ android { buildConfigField("String", "ALIAS", getProperty("ALIAS")) } + buildFeatures { + buildConfig = true + } + buildTypes { release { isMinifyEnabled = false @@ -71,4 +75,4 @@ fun getProperty(value: String): String { } else { System.getenv(value) ?: "\"sample_val\"" } -} \ No newline at end of file +} From c2e79bf233e0ccb750183e05e73329155578a748 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:02:32 +0000 Subject: [PATCH 13/27] Initial plan From 148d9794c8614ada97e85643a0fb54ba0ee2a32f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:30:36 +0000 Subject: [PATCH 14/27] Integrate MCP tools with GGUF Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- app/build.gradle.kts | 6 +- .../com/dark/tool_neuron/di/AppContainer.kt | 15 +- .../tool_neuron/service/McpClientService.kt | 37 ++++- .../dark/tool_neuron/service/McpToolMapper.kt | 68 ++++++++ .../tool_neuron/viewmodel/ChatViewModel.kt | 148 +++++++++++++++++- .../viewmodel/factory/ChatViewModelFactory.kt | 16 +- .../dark/tool_neuron/worker/LlmModelWorker.kt | 21 ++- .../tool_neuron/service/McpToolMapperTest.kt | 38 +++++ gradle/libs.versions.toml | 4 +- 9 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt create mode 100644 app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a0366e91..f3b0a1b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -143,6 +143,10 @@ dependencies { // Debug debugImplementation(libs.androidx.compose.ui.tooling) + + // Tests + testImplementation(libs.junit) + testImplementation(libs.org.json) } fun getProperty(value: String): String { @@ -154,4 +158,4 @@ fun getProperty(value: String): String { } else { System.getenv(value) ?: "\"sample_val\"" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dark/tool_neuron/di/AppContainer.kt b/app/src/main/java/com/dark/tool_neuron/di/AppContainer.kt index 687be60e..14e24fbf 100644 --- a/app/src/main/java/com/dark/tool_neuron/di/AppContainer.kt +++ b/app/src/main/java/com/dark/tool_neuron/di/AppContainer.kt @@ -4,7 +4,9 @@ import android.app.Application import android.content.Context import com.dark.tool_neuron.database.AppDatabase import com.dark.tool_neuron.repo.ChatRepository +import com.dark.tool_neuron.repo.McpServerRepository import com.dark.tool_neuron.repo.ModelRepository +import com.dark.tool_neuron.service.McpClientService import com.dark.tool_neuron.vault.VaultHelper import com.dark.tool_neuron.viewmodel.factory.ChatListViewModelFactory import com.dark.tool_neuron.viewmodel.factory.ChatViewModelFactory @@ -21,6 +23,8 @@ object AppContainer { private lateinit var database: AppDatabase private lateinit var modelRepository: ModelRepository private lateinit var chatRepository: ChatRepository + private lateinit var mcpServerRepository: McpServerRepository + private lateinit var mcpClientService: McpClientService private lateinit var llmModelViewModelFactory: LLMModelViewModelFactory private lateinit var chatListViewModelFactory: ChatListViewModelFactory private lateinit var chatViewModelFactory: ChatViewModelFactory @@ -38,10 +42,17 @@ object AppContainer { ) chatRepository = ChatRepository() + mcpServerRepository = McpServerRepository(database.mcpServerDao()) + mcpClientService = McpClientService() llmModelViewModelFactory = LLMModelViewModelFactory(application, modelRepository) chatListViewModelFactory = ChatListViewModelFactory(chatManager) - chatViewModelFactory = ChatViewModelFactory(chatManager, generationManager) + chatViewModelFactory = ChatViewModelFactory( + chatManager, + generationManager, + mcpServerRepository, + mcpClientService + ) initVault(context) } @@ -84,4 +95,4 @@ object AppContainer { fun isVaultReady(): Boolean = vaultInitialized -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 63b87140..82632d53 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -241,7 +241,7 @@ class McpClientService @Inject constructor() { /** * List available tools from an MCP server */ - private suspend fun listTools(server: McpServer): List = withContext(Dispatchers.IO) { + suspend fun listTools(server: McpServer): List = withContext(Dispatchers.IO) { try { val listToolsRequest = JSONObject().apply { put("jsonrpc", "2.0") @@ -304,6 +304,27 @@ class McpClientService @Inject constructor() { toolName: String, arguments: Map ): Result = withContext(Dispatchers.IO) { + return@withContext callToolInternal(server, toolName, JSONObject(arguments)) + } + + suspend fun callTool( + server: McpServer, + toolName: String, + argumentsJson: String + ): Result = withContext(Dispatchers.IO) { + val parsedArguments = try { + if (argumentsJson.isBlank()) JSONObject() else JSONObject(argumentsJson) + } catch (e: Exception) { + return@withContext Result.failure(Exception("Invalid tool arguments JSON: ${e.message}")) + } + return@withContext callToolInternal(server, toolName, parsedArguments) + } + + private fun callToolInternal( + server: McpServer, + toolName: String, + arguments: JSONObject + ): Result { try { val callToolRequest = JSONObject().apply { put("jsonrpc", "2.0") @@ -311,7 +332,7 @@ class McpClientService @Inject constructor() { put("method", "tools/call") put("params", JSONObject().apply { put("name", toolName) - put("arguments", JSONObject(arguments)) + put("arguments", arguments) }) } @@ -328,11 +349,11 @@ class McpClientService @Inject constructor() { val response = httpClient.newCall(requestBuilder.build()).execute() if (!response.isSuccessful) { - return@withContext Result.failure(Exception("Server returned: ${response.code}")) + return Result.failure(Exception("Server returned: ${response.code}")) } val rawResponseBody = response.body?.string() - ?: return@withContext Result.failure(Exception("Empty response")) + ?: return Result.failure(Exception("Empty response")) // Parse response based on transport type val responseBody = parseResponse(rawResponseBody, server.transportType) @@ -340,15 +361,15 @@ class McpClientService @Inject constructor() { if (jsonResponse.has("error")) { val error = jsonResponse.getJSONObject("error") - return@withContext Result.failure(Exception(error.optString("message", "Unknown error"))) + return Result.failure(Exception(error.optString("message", "Unknown error"))) } val result = jsonResponse.optJSONObject("result") - Result.success(result?.toString() ?: responseBody) - + return Result.success(result?.toString() ?: responseBody) + } catch (e: Exception) { Log.e(TAG, "Failed to call tool: ${e.message}", e) - Result.failure(e) + return Result.failure(e) } } } diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt b/app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt new file mode 100644 index 00000000..7495c1d8 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt @@ -0,0 +1,68 @@ +package com.dark.tool_neuron.service + +import com.dark.tool_neuron.models.table_schema.McpServer +import org.json.JSONArray +import org.json.JSONObject + +data class McpToolReference( + val server: McpServer, + val toolName: String +) + +data class McpToolMapping( + val toolsJson: String, + val toolRegistry: Map +) + +object McpToolMapper { + fun sanitizeIdentifier(value: String): String { + return value.lowercase() + .replace(Regex("[^a-z0-9]+"), "_") + .trim('_') + } + + fun buildMapping(serverTools: Map>): McpToolMapping { + val toolsArray = JSONArray() + val registry = mutableMapOf() + + serverTools.forEach { (server, tools) -> + val serverPrefix = sanitizeIdentifier(server.name).ifBlank { "mcp" } + tools.forEach { tool -> + val toolSlug = sanitizeIdentifier(tool.name).ifBlank { "tool" } + val toolId = "${serverPrefix}_${toolSlug}" + toolsArray.put(buildToolDefinition(toolId, tool)) + registry[toolId] = McpToolReference(server, tool.name) + } + } + + return McpToolMapping( + toolsJson = toolsArray.toString(), + toolRegistry = registry + ) + } + + private fun buildToolDefinition(toolId: String, tool: McpToolInfo): JSONObject { + val function = JSONObject().apply { + put("name", toolId) + tool.description?.takeIf { it.isNotBlank() }?.let { put("description", it) } + put("parameters", buildParameters(tool.inputSchema)) + } + + return JSONObject().apply { + put("type", "function") + put("function", function) + } + } + + private fun buildParameters(inputSchema: String?): JSONObject { + val parsedSchema = inputSchema?.takeIf { it.isNotBlank() }?.let { + runCatching { JSONObject(it) }.getOrNull() + } + + return (parsedSchema ?: JSONObject()).apply { + if (!has("type")) { + put("type", "object") + } + } + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt index 0029b221..c0c3c929 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt @@ -13,7 +13,12 @@ import com.dark.tool_neuron.models.messages.MessageContent import com.dark.tool_neuron.models.messages.Messages import com.dark.tool_neuron.models.messages.RagResultItem import com.dark.tool_neuron.models.messages.Role +import com.dark.tool_neuron.models.table_schema.McpServer import com.dark.tool_neuron.models.table_schema.ModelConfig +import com.dark.tool_neuron.repo.McpServerRepository +import com.dark.tool_neuron.service.McpClientService +import com.dark.tool_neuron.service.McpToolMapper +import com.dark.tool_neuron.service.McpToolReference import com.dark.tool_neuron.state.AppStateManager import com.dark.tool_neuron.worker.ChatManager import com.dark.tool_neuron.worker.DiffusionConfig @@ -26,12 +31,15 @@ import jakarta.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @HiltViewModel class ChatViewModel @Inject constructor( private val chatManager: ChatManager, - private val generationManager: GenerationManager + private val generationManager: GenerationManager, + private val mcpServerRepository: McpServerRepository, + private val mcpClientService: McpClientService ) : ViewModel() { private val _messages = mutableStateListOf() @@ -103,6 +111,13 @@ class ChatViewModel @Inject constructor( private val _currentRagResults = MutableStateFlow>(emptyList()) val currentRagResults: StateFlow> = _currentRagResults + private data class ToolCallInfo( + val name: String, + val argsJson: String + ) + + private var mcpToolRegistry: Map = emptyMap() + // ==================== RAG Controls ==================== fun setRagEnabled(enabled: Boolean) { @@ -240,11 +255,13 @@ class ChatViewModel @Inject constructor( val tokenBatchSize = 3 try { + var pendingToolCall: ToolCallInfo? = null // Prepend RAG context if available val finalPrompt = _currentRagContext.value?.let { ragContext -> "$ragContext\n\n### User Query:\n$prompt" } ?: prompt + syncMcpTools() generationManager.generateTextStreaming(finalPrompt, maxTokens).collect { event -> when (event) { is GenerationEvent.Token -> { @@ -265,6 +282,11 @@ class ChatViewModel @Inject constructor( } is GenerationEvent.Done -> { + val toolCall = pendingToolCall + if (toolCall != null) { + handleToolCallForNewChat(prompt, toolCall) + return@collect + } _streamingAssistantMessage.value = currentGeneratedContent // Don't set _isGenerating.value = false here // It will be set in resetStreamingState() after messages are added @@ -279,7 +301,13 @@ class ChatViewModel @Inject constructor( currentMetrics = event.metrics } - is GenerationEvent.ToolCall -> {} + is GenerationEvent.ToolCall -> { + pendingToolCall = ToolCallInfo(event.name, event.args) + currentGeneratedContent = "" + tokenBuffer.clear() + tokenCount = 0 + _streamingAssistantMessage.value = "" + } } } } catch (e: Exception) { @@ -305,6 +333,7 @@ class ChatViewModel @Inject constructor( val tokenBatchSize = 3 try { + var pendingToolCall: ToolCallInfo? = null var conversationPrompt = generationManager.buildConversationPrompt( _messages, userMessage.content.content ) @@ -314,6 +343,7 @@ class ChatViewModel @Inject constructor( conversationPrompt = "$ragContext\n\n$conversationPrompt" } + syncMcpTools() generationManager.generateTextStreaming(conversationPrompt, maxTokens) .collect { event -> when (event) { @@ -335,6 +365,11 @@ class ChatViewModel @Inject constructor( } is GenerationEvent.Done -> { + val toolCall = pendingToolCall + if (toolCall != null) { + handleToolCallExistingChat(chatId, userMessage, toolCall) + return@collect + } _streamingAssistantMessage.value = currentGeneratedContent // Add user message first if not already added @@ -383,7 +418,13 @@ class ChatViewModel @Inject constructor( currentMetrics = event.metrics } - is GenerationEvent.ToolCall -> {} + is GenerationEvent.ToolCall -> { + pendingToolCall = ToolCallInfo(event.name, event.args) + currentGeneratedContent = "" + tokenBuffer.clear() + tokenCount = 0 + _streamingAssistantMessage.value = "" + } } } } catch (e: Exception) { @@ -524,6 +565,105 @@ class ChatViewModel @Inject constructor( } } + // ==================== MCP Tool Integration ==================== + + private suspend fun syncMcpTools() { + try { + val enabledServers = mcpServerRepository.getEnabledServers().first() + if (enabledServers.isEmpty()) { + mcpToolRegistry = emptyMap() + LlmModelWorker.clearGgufTools() + return + } + + val serverTools = mutableMapOf>() + enabledServers.forEach { server -> + val tools = mcpClientService.listTools(server) + if (tools.isNotEmpty()) { + serverTools[server] = tools + } + } + + if (serverTools.isEmpty()) { + mcpToolRegistry = emptyMap() + LlmModelWorker.clearGgufTools() + return + } + + val mapping = McpToolMapper.buildMapping(serverTools) + mcpToolRegistry = mapping.toolRegistry + + if (mapping.toolRegistry.isEmpty()) { + LlmModelWorker.clearGgufTools() + return + } + + val success = LlmModelWorker.setGgufToolsJson(mapping.toolsJson) + if (!success) { + LlmModelWorker.clearGgufTools() + } + } catch (e: Exception) { + val message = "Failed to refresh MCP tools: ${e.message}" + _error.value = message + AppStateManager.setError(message) + } + } + + private suspend fun handleToolCallForNewChat(prompt: String, toolCall: ToolCallInfo) { + val response = resolveToolCallResponse(toolCall) + _streamingAssistantMessage.value = response + createChatWithMessages(prompt, response, null) + } + + private suspend fun handleToolCallExistingChat( + chatId: String, + userMessage: Messages, + toolCall: ToolCallInfo + ) { + val response = resolveToolCallResponse(toolCall) + _streamingAssistantMessage.value = response + + if (!userMessageAdded) { + _messages.add(userMessage) + userMessageAdded = true + } + + val assistantMessage = Messages( + role = Role.Assistant, + content = MessageContent( + contentType = ContentType.Text, + content = response + ) + ) + _messages.add(assistantMessage) + + chatManager.addAssistantMessage(chatId, response, null) + AppStateManager.setGenerationComplete() + resetStreamingState() + } + + private suspend fun resolveToolCallResponse(toolCall: ToolCallInfo): String { + return executeToolCall(toolCall).fold( + onSuccess = { result -> formatToolResult(toolCall.name, result) }, + onFailure = { error -> + val message = "Tool ${toolCall.name} failed: ${error.message ?: "Unknown error"}" + _error.value = message + AppStateManager.setError(message) + message + } + ) + } + + private suspend fun executeToolCall(toolCall: ToolCallInfo): Result { + val reference = mcpToolRegistry[toolCall.name] + ?: return Result.failure(Exception("Tool not found: ${toolCall.name}")) + return mcpClientService.callTool(reference.server, reference.toolName, toolCall.argsJson) + } + + private fun formatToolResult(toolName: String, result: String): String { + return "Tool $toolName result:\n$result" + } + private fun generateImageForNewChat( prompt: String, negativePrompt: String, @@ -1018,4 +1158,4 @@ class ChatViewModel @Inject constructor( fun hideModelList() { _showModelList.value = false } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/factory/ChatViewModelFactory.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/factory/ChatViewModelFactory.kt index a134d583..e9b39c66 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/factory/ChatViewModelFactory.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/factory/ChatViewModelFactory.kt @@ -2,18 +2,28 @@ package com.dark.tool_neuron.viewmodel.factory import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.dark.tool_neuron.repo.McpServerRepository +import com.dark.tool_neuron.service.McpClientService import com.dark.tool_neuron.viewmodel.ChatViewModel import com.dark.tool_neuron.worker.ChatManager import com.dark.tool_neuron.worker.GenerationManager class ChatViewModelFactory( - private val chatManager: ChatManager, private val generationManager: GenerationManager + private val chatManager: ChatManager, + private val generationManager: GenerationManager, + private val mcpServerRepository: McpServerRepository, + private val mcpClientService: McpClientService ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(ChatViewModel::class.java)) { - return ChatViewModel(chatManager, generationManager) as T + return ChatViewModel( + chatManager, + generationManager, + mcpServerRepository, + mcpClientService + ) as T } throw IllegalArgumentException("Unknown ViewModel class") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/dark/tool_neuron/worker/LlmModelWorker.kt b/app/src/main/java/com/dark/tool_neuron/worker/LlmModelWorker.kt index ffbb9c93..7edeb1c2 100644 --- a/app/src/main/java/com/dark/tool_neuron/worker/LlmModelWorker.kt +++ b/app/src/main/java/com/dark/tool_neuron/worker/LlmModelWorker.kt @@ -309,6 +309,25 @@ object LlmModelWorker { return service?.modelInfoGguf } + suspend fun setGgufToolsJson(toolsJson: String): Boolean { + val svc = ensureServiceBound() + return try { + svc.setToolsJsonGguf(toolsJson) + } catch (e: Exception) { + Log.e(TAG, "Failed to set GGUF tools JSON", e) + false + } + } + + fun clearGgufTools() { + try { + service?.clearToolsGguf() + Log.i(TAG, "Cleared GGUF tools") + } catch (e: Exception) { + Log.e(TAG, "Failed to clear GGUF tools", e) + } + } + // ==================== Diffusion Methods ==================== /** @@ -618,4 +637,4 @@ object LlmModelWorker { Log.i(TAG, "Embedding model download started in background") } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt b/app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt new file mode 100644 index 00000000..2f975569 --- /dev/null +++ b/app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt @@ -0,0 +1,38 @@ +package com.dark.tool_neuron.service + +import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class McpToolMapperTest { + @Test + fun buildMappingCreatesToolRegistry() { + val server = McpServer( + id = "server-1", + name = "Zapier MCP", + url = "https://example.com/mcp", + transportType = McpTransportType.SSE + ) + val tool = McpToolInfo( + name = "send-email", + description = "Send an email", + inputSchema = """{"type":"object","properties":{"to":{"type":"string"}}}""" + ) + + val mapping = McpToolMapper.buildMapping(mapOf(server to listOf(tool))) + val toolsArray = JSONArray(mapping.toolsJson) + + assertEquals(1, toolsArray.length()) + val function = toolsArray.getJSONObject(0).getJSONObject("function") + assertEquals("zapier_mcp_send_email", function.getString("name")) + assertEquals("object", function.getJSONObject("parameters").getString("type")) + + val reference = mapping.toolRegistry["zapier_mcp_send_email"] + assertNotNull(reference) + assertEquals(server, reference?.server) + assertEquals("send-email", reference?.toolName) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f709a8d7..d3811ee3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ xz = "1.11" androidx-espresso-core = "3.7.0" androidx-junit = "1.3.0" junit = "4.13.2" +org-json = "20240303" [libraries] androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } @@ -77,6 +78,7 @@ xz = { group = "org.tukaani", name = "xz", version.ref = "xz" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso-core" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" } junit = { group = "junit", name = "junit", version.ref = "junit" } +org-json = { group = "org.json", name = "json", version.ref = "org-json" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -85,4 +87,4 @@ google-dagger-hilt = { id = "com.google.dagger.hilt.android", version.ref = "dag kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From 89dde9bf01bfea44b3c4a0a5a188fba19e092535 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:32:53 +0000 Subject: [PATCH 15/27] Refine MCP tool call execution Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/service/McpClientService.kt | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 82632d53..52564f98 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -303,28 +303,26 @@ class McpClientService @Inject constructor() { server: McpServer, toolName: String, arguments: Map - ): Result = withContext(Dispatchers.IO) { - return@withContext callToolInternal(server, toolName, JSONObject(arguments)) - } + ): Result = callToolInternal(server, toolName, JSONObject(arguments)) suspend fun callTool( server: McpServer, toolName: String, argumentsJson: String - ): Result = withContext(Dispatchers.IO) { + ): Result { val parsedArguments = try { if (argumentsJson.isBlank()) JSONObject() else JSONObject(argumentsJson) } catch (e: Exception) { - return@withContext Result.failure(Exception("Invalid tool arguments JSON: ${e.message}")) + return Result.failure(Exception("Invalid tool arguments JSON: ${e.message}")) } - return@withContext callToolInternal(server, toolName, parsedArguments) + return callToolInternal(server, toolName, parsedArguments) } - private fun callToolInternal( + private suspend fun callToolInternal( server: McpServer, toolName: String, arguments: JSONObject - ): Result { + ): Result = withContext(Dispatchers.IO) { try { val callToolRequest = JSONObject().apply { put("jsonrpc", "2.0") @@ -347,13 +345,13 @@ class McpClientService @Inject constructor() { } val response = httpClient.newCall(requestBuilder.build()).execute() - + if (!response.isSuccessful) { - return Result.failure(Exception("Server returned: ${response.code}")) + return@withContext Result.failure(Exception("Server returned: ${response.code}")) } val rawResponseBody = response.body?.string() - ?: return Result.failure(Exception("Empty response")) + ?: return@withContext Result.failure(Exception("Empty response")) // Parse response based on transport type val responseBody = parseResponse(rawResponseBody, server.transportType) @@ -361,15 +359,15 @@ class McpClientService @Inject constructor() { if (jsonResponse.has("error")) { val error = jsonResponse.getJSONObject("error") - return Result.failure(Exception(error.optString("message", "Unknown error"))) + return@withContext Result.failure(Exception(error.optString("message", "Unknown error"))) } val result = jsonResponse.optJSONObject("result") - return Result.success(result?.toString() ?: responseBody) + return@withContext Result.success(result?.toString() ?: responseBody) } catch (e: Exception) { Log.e(TAG, "Failed to call tool: ${e.message}", e) - return Result.failure(e) + return@withContext Result.failure(e) } } } From 03cfc52c8c785babdd4b0733c69d683158fe73f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:06:04 +0000 Subject: [PATCH 16/27] Initial plan From d8692ffeb109525818b567b045e196bd7e4bd85c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:21:56 +0000 Subject: [PATCH 17/27] Add MCP server integration tests Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../integration/McpServerIntegrationTest.kt | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt diff --git a/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt b/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt new file mode 100644 index 00000000..aaf0550e --- /dev/null +++ b/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt @@ -0,0 +1,218 @@ +package com.dark.tool_neuron.integration + +import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType +import com.dark.tool_neuron.service.McpToolInfo +import com.dark.tool_neuron.service.McpToolMapper +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test + +/** + * Integration tests for MCP server functionality. + * These tests validate that the MCP client can properly connect to and interact with + * remote MCP servers like Zapier. + */ +class McpServerIntegrationTest { + + /** + * Test that McpServer can be created with the correct configuration + * for connecting to Zapier's MCP endpoint. + */ + @Test + fun createZapierMcpServerConfiguration() { + val zapierUrl = "https://mcp.zapier.com/api/v1/connect?token=example-token" + + val server = McpServer( + id = McpServer.generateId(), + name = "Zapier MCP", + url = zapierUrl, + transportType = McpTransportType.SSE, + apiKey = null, // Token is in URL + description = "Zapier MCP integration for Google Docs tools" + ) + + assertNotNull(server.id) + assertEquals("Zapier MCP", server.name) + assertEquals(zapierUrl, server.url) + assertEquals(McpTransportType.SSE, server.transportType) + assertTrue(server.isEnabled) + } + + /** + * Test parsing of MCP initialize response in SSE format. + */ + @Test + fun parseMcpInitializeResponse() { + val sseResponse = """event: message +data: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"zapier","title":"Zapier MCP","version":"1.0.0"}},"jsonrpc":"2.0","id":1}""" + + // Extract JSON from SSE format + val dataLine = sseResponse.lines().find { it.startsWith("data:") } + assertNotNull(dataLine) + + val jsonStr = dataLine!!.removePrefix("data:").trim() + val json = JSONObject(jsonStr) + + assertEquals("2.0", json.getString("jsonrpc")) + assertEquals(1, json.getInt("id")) + + val result = json.getJSONObject("result") + assertEquals("2024-11-05", result.getString("protocolVersion")) + + val serverInfo = result.getJSONObject("serverInfo") + assertEquals("zapier", serverInfo.getString("name")) + assertEquals("1.0.0", serverInfo.getString("version")) + } + + /** + * Test parsing of MCP tools/list response. + */ + @Test + fun parseMcpToolsListResponse() { + val sseResponse = """event: message +data: {"result":{"tools":[{"name":"google_docs_create_document_from_text","description":"Create a new document from text.","inputSchema":{"type":"object","properties":{"title":{"type":"string"}},"required":[]}}]},"jsonrpc":"2.0","id":2}""" + + val dataLine = sseResponse.lines().find { it.startsWith("data:") } + assertNotNull(dataLine) + + val jsonStr = dataLine!!.removePrefix("data:").trim() + val json = JSONObject(jsonStr) + + val result = json.getJSONObject("result") + val tools = result.getJSONArray("tools") + + assertEquals(1, tools.length()) + + val tool = tools.getJSONObject(0) + assertEquals("google_docs_create_document_from_text", tool.getString("name")) + assertEquals("Create a new document from text.", tool.getString("description")) + + val inputSchema = tool.getJSONObject("inputSchema") + assertEquals("object", inputSchema.getString("type")) + } + + /** + * Test that McpToolMapper correctly maps Zapier tools to the LLM format. + */ + @Test + fun mapZapierToolsToLlmFormat() { + val server = McpServer( + id = "zapier-1", + name = "Zapier MCP", + url = "https://mcp.zapier.com/api/v1/connect", + transportType = McpTransportType.SSE + ) + + val tools = listOf( + McpToolInfo( + name = "google_docs_create_document_from_text", + description = "Create a new document from text. Also supports limited HTML.", + inputSchema = """{"type":"object","properties":{"title":{"type":"string","description":"Document Name"},"file":{"type":"string","description":"Document Content"}},"required":["instructions"]}""" + ), + McpToolInfo( + name = "google_docs_find_a_document", + description = "Search for a specific document by name.", + inputSchema = """{"type":"object","properties":{"title":{"type":"string","description":"Document Name"}},"required":["instructions"]}""" + ) + ) + + val mapping = McpToolMapper.buildMapping(mapOf(server to tools)) + + // Check that tools JSON is valid + val toolsArray = JSONArray(mapping.toolsJson) + assertEquals(2, toolsArray.length()) + + // Check first tool + val firstTool = toolsArray.getJSONObject(0) + assertEquals("function", firstTool.getString("type")) + + val function = firstTool.getJSONObject("function") + // Tool name should be sanitized: "zapier_mcp_google_docs_create_document_from_text" + assertTrue(function.getString("name").contains("google_docs_create_document_from_text")) + assertTrue(function.has("description")) + + // Check tool registry + assertEquals(2, mapping.toolRegistry.size) + + // Check that registry maps back to original tool names + val firstEntry = mapping.toolRegistry.values.first() + assertEquals(server, firstEntry.server) + assertTrue(firstEntry.toolName.startsWith("google_docs_")) + } + + /** + * Test that tool call request is properly formatted for MCP protocol. + */ + @Test + fun formatMcpToolCallRequest() { + val toolName = "google_docs_create_document_from_text" + val arguments = JSONObject().apply { + put("instructions", "Create a document titled 'Test' with content 'Hello World'") + put("output_hint", "just the document URL") + put("title", "Test Document") + put("file", "Hello World") + } + + val request = JSONObject().apply { + put("jsonrpc", "2.0") + put("id", System.currentTimeMillis()) + put("method", "tools/call") + put("params", JSONObject().apply { + put("name", toolName) + put("arguments", arguments) + }) + } + + assertEquals("2.0", request.getString("jsonrpc")) + assertEquals("tools/call", request.getString("method")) + + val params = request.getJSONObject("params") + assertEquals(toolName, params.getString("name")) + + val args = params.getJSONObject("arguments") + assertEquals("Test Document", args.getString("title")) + assertEquals("Hello World", args.getString("file")) + } + + /** + * Test SSE transport type is correctly identified for Zapier MCP. + */ + @Test + fun identifyZapierTransportType() { + // Zapier uses SSE transport + val zapierServer = McpServer( + id = "zapier", + name = "Zapier", + url = "https://mcp.zapier.com/api/v1/connect", + transportType = McpTransportType.SSE + ) + + assertEquals(McpTransportType.SSE, zapierServer.transportType) + + // Alternative: Streamable HTTP transport + val httpServer = McpServer( + id = "custom", + name = "Custom MCP", + url = "https://api.example.com/mcp", + transportType = McpTransportType.STREAMABLE_HTTP + ) + + assertEquals(McpTransportType.STREAMABLE_HTTP, httpServer.transportType) + } + + /** + * Test that server ID generation produces unique IDs. + */ + @Test + fun generateUniqueServerIds() { + val ids = mutableSetOf() + repeat(100) { + ids.add(McpServer.generateId()) + } + + // All 100 IDs should be unique + assertEquals(100, ids.size) + } +} From 8872229375c6c27d6cee0df59169b4ab68835f65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:25:13 +0000 Subject: [PATCH 18/27] Fix MCP integration test input schemas to include required instructions field Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../dark/tool_neuron/integration/McpServerIntegrationTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt b/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt index aaf0550e..7d7bff99 100644 --- a/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt +++ b/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt @@ -109,12 +109,12 @@ data: {"result":{"tools":[{"name":"google_docs_create_document_from_text","descr McpToolInfo( name = "google_docs_create_document_from_text", description = "Create a new document from text. Also supports limited HTML.", - inputSchema = """{"type":"object","properties":{"title":{"type":"string","description":"Document Name"},"file":{"type":"string","description":"Document Content"}},"required":["instructions"]}""" + inputSchema = """{"type":"object","properties":{"instructions":{"type":"string","description":"Instructions for running this tool"},"title":{"type":"string","description":"Document Name"},"file":{"type":"string","description":"Document Content"}},"required":["instructions"]}""" ), McpToolInfo( name = "google_docs_find_a_document", description = "Search for a specific document by name.", - inputSchema = """{"type":"object","properties":{"title":{"type":"string","description":"Document Name"}},"required":["instructions"]}""" + inputSchema = """{"type":"object","properties":{"instructions":{"type":"string","description":"Instructions for running this tool"},"title":{"type":"string","description":"Document Name"}},"required":["instructions"]}""" ) ) From cdb24df2f75379c801c1c49775d8e99d163ccca9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:16:43 +0000 Subject: [PATCH 19/27] Fix Streamable HTTP transport and improve MCP server tests - Fix parseResponse() to always try SSE parsing regardless of transport type - Rename test class from McpServerIntegrationTest to McpServerTest - Use exact assertions instead of permissive contains() checks - Add helper function documentation and UUID format validation - Reduce UUID test iterations from 100 to 10 for efficiency Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/service/McpClientService.kt | 12 +-- ...verIntegrationTest.kt => McpServerTest.kt} | 99 +++++++++++-------- 2 files changed, 64 insertions(+), 47 deletions(-) rename app/src/test/java/com/dark/tool_neuron/integration/{McpServerIntegrationTest.kt => McpServerTest.kt} (70%) diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 52564f98..6f319d5a 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -87,14 +87,14 @@ class McpClientService @Inject constructor() { } /** - * Parse response body, handling SSE format for SSE transport. - * For Streamable HTTP, returns the raw JSON body (no SSE envelope to parse). + * Parse response body, handling SSE format automatically. + * Some MCP servers return SSE-formatted responses regardless of the declared transport type, + * so we detect and parse SSE format for both transport types. */ private fun parseResponse(responseBody: String, transportType: McpTransportType): String { - return when (transportType) { - McpTransportType.SSE -> parseSseResponse(responseBody) - McpTransportType.STREAMABLE_HTTP -> responseBody // Already JSON, no SSE envelope to parse - } + // Always try to parse SSE format first, as some servers return SSE regardless of transport type + // The parseSseResponse function will return the original body if it's not SSE format + return parseSseResponse(responseBody) } /** diff --git a/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt b/app/src/test/java/com/dark/tool_neuron/integration/McpServerTest.kt similarity index 70% rename from app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt rename to app/src/test/java/com/dark/tool_neuron/integration/McpServerTest.kt index 7d7bff99..63bb7cef 100644 --- a/app/src/test/java/com/dark/tool_neuron/integration/McpServerIntegrationTest.kt +++ b/app/src/test/java/com/dark/tool_neuron/integration/McpServerTest.kt @@ -10,11 +10,20 @@ import org.junit.Assert.* import org.junit.Test /** - * Integration tests for MCP server functionality. - * These tests validate that the MCP client can properly connect to and interact with - * remote MCP servers like Zapier. + * Unit tests for MCP server-related functionality. + * These tests validate McpToolMapper functionality, JSON parsing, + * and configuration objects without connecting to real MCP servers. */ -class McpServerIntegrationTest { +class McpServerTest { + + // Helper function to parse SSE response format. + // This is a simplified version for tests that extracts JSON from single-event SSE responses. + // The production code in McpClientService.parseSseResponse() handles multiple events and validates JSON. + private fun parseSseData(sseResponse: String): String { + val dataLine = sseResponse.lines().find { it.startsWith("data:") } + ?: return sseResponse + return dataLine.removePrefix("data:").trim() + } /** * Test that McpServer can be created with the correct configuration @@ -41,18 +50,15 @@ class McpServerIntegrationTest { } /** - * Test parsing of MCP initialize response in SSE format. + * Test parsing of MCP initialize response in SSE format using helper function. */ @Test fun parseMcpInitializeResponse() { val sseResponse = """event: message data: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"zapier","title":"Zapier MCP","version":"1.0.0"}},"jsonrpc":"2.0","id":1}""" - // Extract JSON from SSE format - val dataLine = sseResponse.lines().find { it.startsWith("data:") } - assertNotNull(dataLine) - - val jsonStr = dataLine!!.removePrefix("data:").trim() + // Use helper function to extract JSON from SSE format + val jsonStr = parseSseData(sseResponse) val json = JSONObject(jsonStr) assertEquals("2.0", json.getString("jsonrpc")) @@ -74,10 +80,8 @@ data: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listCh val sseResponse = """event: message data: {"result":{"tools":[{"name":"google_docs_create_document_from_text","description":"Create a new document from text.","inputSchema":{"type":"object","properties":{"title":{"type":"string"}},"required":[]}}]},"jsonrpc":"2.0","id":2}""" - val dataLine = sseResponse.lines().find { it.startsWith("data:") } - assertNotNull(dataLine) - - val jsonStr = dataLine!!.removePrefix("data:").trim() + // Use helper function to extract JSON from SSE format + val jsonStr = parseSseData(sseResponse) val json = JSONObject(jsonStr) val result = json.getJSONObject("result") @@ -124,22 +128,29 @@ data: {"result":{"tools":[{"name":"google_docs_create_document_from_text","descr val toolsArray = JSONArray(mapping.toolsJson) assertEquals(2, toolsArray.length()) - // Check first tool + // Check first tool structure val firstTool = toolsArray.getJSONObject(0) assertEquals("function", firstTool.getString("type")) val function = firstTool.getJSONObject("function") - // Tool name should be sanitized: "zapier_mcp_google_docs_create_document_from_text" - assertTrue(function.getString("name").contains("google_docs_create_document_from_text")) + // Verify exact tool name format: "zapier_mcp_google_docs_create_document_from_text" + assertEquals("zapier_mcp_google_docs_create_document_from_text", function.getString("name")) assertTrue(function.has("description")) - // Check tool registry + // Check tool registry size and contents assertEquals(2, mapping.toolRegistry.size) - // Check that registry maps back to original tool names - val firstEntry = mapping.toolRegistry.values.first() - assertEquals(server, firstEntry.server) - assertTrue(firstEntry.toolName.startsWith("google_docs_")) + // Verify exact tool name mapping in registry + val toolNames = mapping.toolRegistry.values.map { it.toolName }.toSet() + assertEquals( + setOf("google_docs_create_document_from_text", "google_docs_find_a_document"), + toolNames + ) + + // Verify all entries reference the same server + mapping.toolRegistry.values.forEach { entry -> + assertEquals(server, entry.server) + } } /** @@ -155,9 +166,10 @@ data: {"result":{"tools":[{"name":"google_docs_create_document_from_text","descr put("file", "Hello World") } + // Use fixed ID for deterministic test behavior val request = JSONObject().apply { put("jsonrpc", "2.0") - put("id", System.currentTimeMillis()) + put("id", 123L) put("method", "tools/call") put("params", JSONObject().apply { put("name", toolName) @@ -166,6 +178,7 @@ data: {"result":{"tools":[{"name":"google_docs_create_document_from_text","descr } assertEquals("2.0", request.getString("jsonrpc")) + assertEquals(123L, request.getLong("id")) assertEquals("tools/call", request.getString("method")) val params = request.getJSONObject("params") @@ -177,42 +190,46 @@ data: {"result":{"tools":[{"name":"google_docs_create_document_from_text","descr } /** - * Test SSE transport type is correctly identified for Zapier MCP. + * Test that both transport types can be assigned to McpServer. */ @Test - fun identifyZapierTransportType() { - // Zapier uses SSE transport - val zapierServer = McpServer( - id = "zapier", - name = "Zapier", - url = "https://mcp.zapier.com/api/v1/connect", + fun verifyTransportTypeAssignment() { + // SSE transport type + val sseServer = McpServer( + id = "server-sse", + name = "SSE Server", + url = "https://mcp.example.com/sse", transportType = McpTransportType.SSE ) + assertEquals(McpTransportType.SSE, sseServer.transportType) - assertEquals(McpTransportType.SSE, zapierServer.transportType) - - // Alternative: Streamable HTTP transport + // Streamable HTTP transport type val httpServer = McpServer( - id = "custom", - name = "Custom MCP", - url = "https://api.example.com/mcp", + id = "server-http", + name = "HTTP Server", + url = "https://mcp.example.com/http", transportType = McpTransportType.STREAMABLE_HTTP ) - assertEquals(McpTransportType.STREAMABLE_HTTP, httpServer.transportType) } /** - * Test that server ID generation produces unique IDs. + * Test that server ID generation produces unique UUIDs. */ @Test fun generateUniqueServerIds() { val ids = mutableSetOf() - repeat(100) { + // Generate 10 IDs to demonstrate uniqueness with reasonable confidence + repeat(10) { ids.add(McpServer.generateId()) } - // All 100 IDs should be unique - assertEquals(100, ids.size) + // All 10 IDs should be unique + assertEquals(10, ids.size) + + // Verify IDs are valid UUID format (lowercase hexadecimal) + ids.forEach { id -> + assertTrue("ID should be a valid UUID format", id.matches(Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"))) + } } } From dd3e72c691f45fec594aeeff9c2222869057350b Mon Sep 17 00:00:00 2001 From: Ahmed Elgharabawy <131464726+Godzilla675@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:24:48 +0000 Subject: [PATCH 20/27] Fix MCP tool calling bugs: sanitizer, fallback parsing, error handling - Fix sanitizeIdentifier creating double underscores for consecutive special characters (e.g., 'My--Tool' now correctly becomes 'my_tool') - Add text-parsing fallback when native grammar fails to emit ToolCall events, preventing raw JSON from being displayed to users - Fix race condition in executeToolCall by retrying syncMcpTools once if tool registry lookup returns null - Clear stale tools in syncMcpTools catch block to prevent broken state - Fix singleton close() not shutting down OkHttpClient dispatcher (which made subsequent requests fail permanently) - Validate SSE parsed data has JSON-RPC fields before accepting - Improve listTools error logging with server name context - Add tests for sanitizeIdentifier edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 61 +++++++++++++++++++ .../tool_neuron/service/McpClientService.kt | 24 +++++--- .../dark/tool_neuron/service/McpToolMapper.kt | 1 + .../tool_neuron/viewmodel/ChatViewModel.kt | 58 ++++++++++++++++-- .../tool_neuron/service/McpToolMapperTest.kt | 16 +++++ 5 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..26573c44 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,61 @@ +# Copilot Instructions for ToolNeuron + +## Build & Test + +```bash +# Build debug APK +./gradlew assembleDebug + +# Build release APK (requires signing config in local.properties) +./gradlew assembleRelease + +# Run all unit tests +./gradlew test + +# Run tests for a single module +./gradlew :app:test +./gradlew :memory-vault:test +./gradlew :neuron-packet:test + +# Run a single test class +./gradlew :app:testDebugUnitTest --tests "com.dark.tool_neuron.McpToolMapperTest" +``` + +**Requirements:** JDK 17, Android SDK 36, NDK 26.x. The `neuron-packet` module requires OpenSSL prebuilt libraries — see `neuron-packet/SETUP.md`. + +## Architecture + +This is an Android app (Kotlin + C++) that runs LLMs and Stable Diffusion entirely on-device. It's a multi-module Gradle project: + +- **`app`** — Main application. Jetpack Compose UI, MVVM with Hilt DI, Room database. Package: `com.dark.tool_neuron`. +- **`memory-vault`** — Encrypted binary storage engine with WAL crash recovery, LZ4 compression, full-text and vector indices. Package: `com.memoryvault`. See `docs/MemoryVault.MD` for the storage format spec. +- **`neuron-packet`** — Secure data export/import with AES-256-GCM encryption. Has both Kotlin and C++ (JNI) sides. Package: `com.neuronpacket`. The C++ code lives in `neuron-packet/src/main/cpp/` and builds via CMake. + +### AI inference layer + +Native inference is provided by pre-built AAR libraries in `libs/`: +- `ai_gguf-release.aar` — llama.cpp bindings for text generation (GGUF models) +- `ai_sd-release.aar` — Stable Diffusion 1.5 bindings for image generation + +These are wrapped by engine classes in `app/.../engine/`: +- `GGUFEngine` — loads GGUF models, generates text, supports function calling with tool grammars +- `DiffusionEngine` — loads SD models, generates images +- `EmbeddingEngine` — generates text embeddings for RAG/vector search + +`LLMService` is a bound Android Service that exposes these engines via AIDL IPC. + +### Data flow + +`UI (Compose screens)` → `ViewModel (@HiltViewModel)` → `Repository` → `Room DAO / MemoryVault` + +ViewModels expose `StateFlow` for reactive UI updates. All async work uses Kotlin Coroutines with `viewModelScope`. + +## Key Conventions + +- **DI:** Hilt everywhere. Activities use `@AndroidEntryPoint`, ViewModels use `@HiltViewModel`. All modules are defined in `app/.../di/HiltModules.kt` and installed in `SingletonComponent`. +- **Navigation:** Jetpack Compose NavHost in `MainActivity`. Routes are defined as a `Screen` sealed class. Uses slide + fade transitions. +- **Serialization:** `kotlinx.serialization` for JSON. Room entities live in `models/table_schema/`. +- **NDK targets:** `arm64-v8a` and `x86_64` only. +- **Build config:** Properties are read from `local.properties` or environment variables via `getProperty()` (defined in each module's `build.gradle.kts`). The `ALIAS` property is used for build config fields. +- **UI constants:** Shared sizing/padding values are in `global/Standards.kt`. +- **Version catalog:** All dependency versions are managed in `gradle/libs.versions.toml`. diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt index 6f319d5a..d5b07b1f 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpClientService.kt @@ -60,16 +60,12 @@ class McpClientService @Inject constructor() { .build() /** - * Clean up resources associated with the underlying OkHttpClient. - * This should be called when the McpClientService is no longer needed. + * Clean up idle resources without destroying the singleton OkHttpClient. + * Only evicts idle connections — the client remains usable for future requests. */ fun close() { try { - // Shut down the executor service used by the dispatcher - httpClient.dispatcher.executorService.shutdown() - // Evict all connections from the connection pool httpClient.connectionPool.evictAll() - // Close any configured cache httpClient.cache?.close() } catch (e: Exception) { Log.w(TAG, "Error while closing OkHttpClient resources", e) @@ -125,10 +121,15 @@ class McpClientService @Inject constructor() { // Multiple data lines in same event should be joined with newlines per SSE spec val joinedData = dataLines.joinToString("\n") { it.removePrefix("data:").trim() } - // Validate that the joined data is valid JSON to avoid propagating malformed JSON-RPC + // Validate that the joined data is a valid JSON-RPC response return try { - JSONObject(joinedData) - joinedData + val json = JSONObject(joinedData) + if (!json.has("jsonrpc") || (!json.has("result") && !json.has("error") && !json.has("id"))) { + Log.w(TAG, "SSE data missing JSON-RPC fields; returning raw response") + responseBody + } else { + joinedData + } } catch (e: Exception) { Log.w(TAG, "SSE data is not valid JSON; returning raw SSE response body", e) responseBody @@ -263,6 +264,7 @@ class McpClientService @Inject constructor() { val response = httpClient.newCall(requestBuilder.build()).execute() if (!response.isSuccessful) { + Log.w(TAG, "listTools failed for '${server.name}': HTTP ${response.code} ${response.message}") return@withContext emptyList() } @@ -272,6 +274,8 @@ class McpClientService @Inject constructor() { val jsonResponse = JSONObject(responseBody) if (jsonResponse.has("error")) { + val error = jsonResponse.getJSONObject("error") + Log.w(TAG, "listTools JSON-RPC error for '${server.name}': ${error.optString("message", "Unknown")}") return@withContext emptyList() } @@ -291,7 +295,7 @@ class McpClientService @Inject constructor() { tools } catch (e: Exception) { - Log.e(TAG, "Failed to list tools: ${e.message}", e) + Log.e(TAG, "Failed to list tools for '${server.name}': ${e.message}", e) emptyList() } } diff --git a/app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt b/app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt index 7495c1d8..9973b526 100644 --- a/app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt +++ b/app/src/main/java/com/dark/tool_neuron/service/McpToolMapper.kt @@ -18,6 +18,7 @@ object McpToolMapper { fun sanitizeIdentifier(value: String): String { return value.lowercase() .replace(Regex("[^a-z0-9]+"), "_") + .replace(Regex("_+"), "_") .trim('_') } diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt index c0c3c929..67497aac 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt @@ -282,7 +282,11 @@ class ChatViewModel @Inject constructor( } is GenerationEvent.Done -> { - val toolCall = pendingToolCall + var toolCall = pendingToolCall + // Fallback: try parsing text as tool call if no ToolCall event was emitted + if (toolCall == null && mcpToolRegistry.isNotEmpty()) { + toolCall = tryParseToolCallFromText(currentGeneratedContent) + } if (toolCall != null) { handleToolCallForNewChat(prompt, toolCall) return@collect @@ -365,7 +369,11 @@ class ChatViewModel @Inject constructor( } is GenerationEvent.Done -> { - val toolCall = pendingToolCall + var toolCall = pendingToolCall + // Fallback: try parsing text as tool call if no ToolCall event was emitted + if (toolCall == null && mcpToolRegistry.isNotEmpty()) { + toolCall = tryParseToolCallFromText(currentGeneratedContent) + } if (toolCall != null) { handleToolCallExistingChat(chatId, userMessage, toolCall) return@collect @@ -603,6 +611,8 @@ class ChatViewModel @Inject constructor( LlmModelWorker.clearGgufTools() } } catch (e: Exception) { + mcpToolRegistry = emptyMap() + LlmModelWorker.clearGgufTools() val message = "Failed to refresh MCP tools: ${e.message}" _error.value = message AppStateManager.setError(message) @@ -655,8 +665,24 @@ class ChatViewModel @Inject constructor( } private suspend fun executeToolCall(toolCall: ToolCallInfo): Result { - val reference = mcpToolRegistry[toolCall.name] - ?: return Result.failure(Exception("Tool not found: ${toolCall.name}")) + var reference = mcpToolRegistry[toolCall.name] + + // Retry once if registry may not be populated yet + if (reference == null) { + try { + syncMcpTools() + reference = mcpToolRegistry[toolCall.name] + } catch (e: Exception) { + return Result.failure( + Exception("Failed to sync MCP tools for ${toolCall.name}: ${e.message}", e) + ) + } + } + + if (reference == null) { + return Result.failure(Exception("Tool not found: ${toolCall.name}")) + } + return mcpClientService.callTool(reference.server, reference.toolName, toolCall.argsJson) } @@ -664,6 +690,30 @@ class ChatViewModel @Inject constructor( return "Tool $toolName result:\n$result" } + /** + * Attempt to parse a tool call from raw text output. + * Handles cases where the native grammar fails to emit a ToolCall event + * and instead outputs JSON text directly. + */ + private fun tryParseToolCallFromText(text: String): ToolCallInfo? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return null + + return try { + val json = org.json.JSONObject(trimmed) + val name = json.optString("name", "").ifBlank { return null } + val args = json.optJSONObject("arguments")?.toString() + ?: json.optString("arguments", "").ifBlank { "{}" } + + // Only accept if the tool name is in our registry + if (mcpToolRegistry.containsKey(name)) { + ToolCallInfo(name, args) + } else null + } catch (_: Exception) { + null + } + } + private fun generateImageForNewChat( prompt: String, negativePrompt: String, diff --git a/app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt b/app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt index 2f975569..55e09aa7 100644 --- a/app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt +++ b/app/src/test/java/com/dark/tool_neuron/service/McpToolMapperTest.kt @@ -35,4 +35,20 @@ class McpToolMapperTest { assertEquals(server, reference?.server) assertEquals("send-email", reference?.toolName) } + + @Test + fun sanitizeIdentifierCollapsesConsecutiveSpecialChars() { + assertEquals("my_tool", McpToolMapper.sanitizeIdentifier("My--Tool")) + assertEquals("a_b", McpToolMapper.sanitizeIdentifier("a---b")) + assertEquals("hello_world", McpToolMapper.sanitizeIdentifier(" hello world ")) + assertEquals("test", McpToolMapper.sanitizeIdentifier("---test---")) + assertEquals("a_b_c", McpToolMapper.sanitizeIdentifier("a..b..c")) + } + + @Test + fun sanitizeIdentifierHandlesEdgeCases() { + assertEquals("mcp", McpToolMapper.sanitizeIdentifier("").ifBlank { "mcp" }) + assertEquals("abc123", McpToolMapper.sanitizeIdentifier("ABC123")) + assertEquals("tool_name_v2", McpToolMapper.sanitizeIdentifier("tool-name-v2")) + } } From 19e5004428796a5e3047b39eb7535d0ae8e9546c Mon Sep 17 00:00:00 2001 From: Ahmed Elgharabawy <131464726+Godzilla675@users.noreply.github.com> Date: Sat, 21 Feb 2026 23:46:34 +0000 Subject: [PATCH 21/27] feat: Add MCP Store and Termux integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP Store screen with browsable registry of MCP servers - Fetches registry from remote GitHub URL, falls back to bundled JSON - Category filtering, search, one-tap install to Room database - Badges for API key requirements and Termux dependencies - Termux integration for running local Python MCP servers - TermuxBridge utility: detect Termux, run commands via RUN_COMMAND intent - pip install flow for Python-based MCP servers - Auto-configure localhost URLs for local servers - Setup dialog guides users to install Termux if not present - Database migration v5→v6: add isLocal and sourceStoreId columns - Navigation: McpStore route + Store button on McpServersScreen top bar - Registry seeded with 10 popular MCP servers (Brave, GitHub, DuckDuckGo, etc.) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 5 + app/src/main/assets/mcp-registry.json | 156 +++++++ .../dark/tool_neuron/activity/MainActivity.kt | 13 +- .../dark/tool_neuron/database/AppDatabase.kt | 11 +- .../tool_neuron/database/dao/McpServerDao.kt | 3 + .../com/dark/tool_neuron/di/HiltModules.kt | 13 + .../dark/tool_neuron/models/McpStoreEntry.kt | 42 ++ .../models/table_schema/McpServer.kt | 8 +- .../tool_neuron/repo/McpServerRepository.kt | 12 + .../tool_neuron/repo/McpStoreRepository.kt | 139 ++++++ .../dark/tool_neuron/service/TermuxBridge.kt | 144 ++++++ .../tool_neuron/ui/screen/McpServersScreen.kt | 6 + .../tool_neuron/ui/screen/McpStoreScreen.kt | 434 ++++++++++++++++++ .../viewmodel/McpStoreViewModel.kt | 177 +++++++ 14 files changed, 1157 insertions(+), 6 deletions(-) create mode 100644 app/src/main/assets/mcp-registry.json create mode 100644 app/src/main/java/com/dark/tool_neuron/models/McpStoreEntry.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/repo/McpStoreRepository.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/service/TermuxBridge.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt create mode 100644 app/src/main/java/com/dark/tool_neuron/viewmodel/McpStoreViewModel.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 25bf8166..c31205b9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,12 @@ + + + + + + + @Query("SELECT * FROM mcp_servers ORDER BY name ASC") + suspend fun getAllServersSnapshot(): List } diff --git a/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt b/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt index 8be54ff1..bf835929 100644 --- a/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt +++ b/app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt @@ -5,6 +5,7 @@ import com.dark.tool_neuron.engine.EmbeddingEngine import com.dark.tool_neuron.repo.ChatRepository import com.dark.tool_neuron.repo.McpServerRepository + import com.dark.tool_neuron.repo.McpStoreRepository import com.dark.tool_neuron.repo.ModelRepository import com.dark.tool_neuron.repo.RagRepository import com.dark.tool_neuron.service.McpClientService @@ -75,6 +76,18 @@ mcpServerDao = database.mcpServerDao() ) } + + @Provides + @Singleton + fun provideMcpStoreRepository( + @ApplicationContext context: Context, + mcpServerRepository: McpServerRepository + ): McpStoreRepository { + return McpStoreRepository( + context = context, + mcpServerRepository = mcpServerRepository + ) + } } @Module diff --git a/app/src/main/java/com/dark/tool_neuron/models/McpStoreEntry.kt b/app/src/main/java/com/dark/tool_neuron/models/McpStoreEntry.kt new file mode 100644 index 00000000..37ab4d45 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/models/McpStoreEntry.kt @@ -0,0 +1,42 @@ +package com.dark.tool_neuron.models + +import kotlinx.serialization.Serializable + +/** + * Represents an MCP server entry in the remote registry (MCP Store). + * Users browse these entries and install them as local McpServer configurations. + */ +@Serializable +data class McpStoreEntry( + val id: String, + val name: String, + val description: String, + val url: String, + val transportType: String = "SSE", + val category: String = "general", + val requiresApiKey: Boolean = false, + val requiresTermux: Boolean = false, + val pipPackage: String? = null, + val setupCommand: String? = null, + val defaultPort: Int? = null, + val author: String = "", + val tags: List = emptyList(), + val iconName: String? = null, + val setupInstructions: String? = null +) + +/** + * Categories for MCP Store entries + */ +object McpStoreCategories { + const val ALL = "All" + const val SEARCH = "Search" + const val CODE = "Code" + const val DATA = "Data" + const val FILES = "Files" + const val AI = "AI" + const val UTILITIES = "Utilities" + const val LOCAL = "Local (Termux)" + + val all = listOf(ALL, SEARCH, CODE, DATA, FILES, AI, UTILITIES, LOCAL) +} diff --git a/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt index a8087cd4..3fed8ce6 100644 --- a/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt +++ b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt @@ -61,7 +61,13 @@ data class McpServer( val description: String = "", /** Custom headers as JSON string (e.g., for additional auth) */ - val customHeadersJson: String? = null + val customHeadersJson: String? = null, + + /** Whether this server runs locally (e.g., via Termux) */ + val isLocal: Boolean = false, + + /** ID of the MCP Store entry this server was installed from */ + val sourceStoreId: String? = null ) { companion object { fun generateId(): String = java.util.UUID.randomUUID().toString() diff --git a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt index bb8c5726..c1eefb0d 100644 --- a/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt +++ b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt @@ -144,10 +144,22 @@ class McpServerRepository @Inject constructor( */ fun getEnabledServerCount(): Flow = mcpServerDao.getEnabledServerCount() + /** + * Get all servers as a one-shot snapshot (non-Flow) + */ + suspend fun getAllServersSnapshot(): List = mcpServerDao.getAllServersSnapshot() + /** * Get the current connection status for a server */ fun getConnectionStatus(serverId: String): McpConnectionStatus { return _connectionStatuses.value[serverId] ?: McpConnectionStatus.DISCONNECTED } + + /** + * Add a pre-built McpServer directly (used by MCP Store). + */ + suspend fun addServerDirect(server: McpServer) { + mcpServerDao.insertServer(server) + } } diff --git a/app/src/main/java/com/dark/tool_neuron/repo/McpStoreRepository.kt b/app/src/main/java/com/dark/tool_neuron/repo/McpStoreRepository.kt new file mode 100644 index 00000000..442e3ec2 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/repo/McpStoreRepository.kt @@ -0,0 +1,139 @@ +package com.dark.tool_neuron.repo + +import android.content.Context +import android.util.Log +import com.dark.tool_neuron.models.McpStoreCategories +import com.dark.tool_neuron.models.McpStoreEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Repository for fetching and managing MCP Store registry entries. + * Loads a bundled fallback from assets and can refresh from a remote URL. + */ +@Singleton +class McpStoreRepository @Inject constructor( + private val context: Context, + private val mcpServerRepository: McpServerRepository +) { + companion object { + private const val TAG = "McpStoreRepository" + private const val REGISTRY_ASSET = "mcp-registry.json" + private const val REMOTE_REGISTRY_URL = + "https://raw.githubusercontent.com/Siddhesh2377/ToolNeuron/re-write/app/src/main/assets/mcp-registry.json" + } + + private val json = Json { ignoreUnknownKeys = true } + private val client = OkHttpClient() + + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + /** + * Load registry entries. Tries remote first, falls back to bundled asset. + */ + suspend fun loadEntries() { + if (_entries.value.isNotEmpty() && !_isLoading.value) return + _isLoading.value = true + _error.value = null + try { + val remote = fetchRemoteRegistry() + if (remote != null && remote.isNotEmpty()) { + _entries.value = remote + Log.d(TAG, "Loaded ${remote.size} entries from remote registry") + } else { + val local = loadBundledRegistry() + _entries.value = local + Log.d(TAG, "Loaded ${local.size} entries from bundled registry") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load registry, falling back to bundled", e) + try { + _entries.value = loadBundledRegistry() + } catch (e2: Exception) { + _error.value = "Failed to load MCP store: ${e2.message}" + Log.e(TAG, "Failed to load bundled registry", e2) + } + } finally { + _isLoading.value = false + } + } + + /** + * Force refresh from remote registry. + */ + suspend fun refresh() { + _entries.value = emptyList() + loadEntries() + } + + /** + * Filter entries by category and search query. + */ + fun filterEntries( + entries: List, + category: String, + searchQuery: String + ): List { + return entries.filter { entry -> + val matchesCategory = category == McpStoreCategories.ALL || + entry.category.equals(category, ignoreCase = true) || + (category == McpStoreCategories.LOCAL && entry.requiresTermux) + val matchesSearch = searchQuery.isBlank() || + entry.name.contains(searchQuery, ignoreCase = true) || + entry.description.contains(searchQuery, ignoreCase = true) || + entry.tags.any { it.contains(searchQuery, ignoreCase = true) } + matchesCategory && matchesSearch + } + } + + /** + * Check if a store entry is already installed as an MCP server. + */ + suspend fun isInstalled(storeEntryId: String): Boolean { + // Check all servers for a matching sourceStoreId + val servers = mcpServerRepository.getAllServersSnapshot() + return servers.any { it.sourceStoreId == storeEntryId } + } + + fun clearError() { + _error.value = null + } + + private suspend fun fetchRemoteRegistry(): List? = withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(REMOTE_REGISTRY_URL).build() + val response = client.newCall(request).execute() + if (response.isSuccessful) { + val body = response.body?.string() ?: return@withContext null + json.decodeFromString>(body) + } else { + Log.w(TAG, "Remote registry returned ${response.code}") + null + } + } catch (e: Exception) { + Log.w(TAG, "Could not fetch remote registry: ${e.message}") + null + } + } + + private suspend fun loadBundledRegistry(): List = withContext(Dispatchers.IO) { + val inputStream = context.assets.open(REGISTRY_ASSET) + val jsonString = inputStream.bufferedReader().use { it.readText() } + json.decodeFromString(jsonString) + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/service/TermuxBridge.kt b/app/src/main/java/com/dark/tool_neuron/service/TermuxBridge.kt new file mode 100644 index 00000000..afd5eba9 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/service/TermuxBridge.kt @@ -0,0 +1,144 @@ +package com.dark.tool_neuron.service + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.util.Log + +/** + * Bridge for interacting with the Termux app to run local MCP servers. + * Uses Termux's RUN_COMMAND intent API to execute commands. + */ +object TermuxBridge { + private const val TAG = "TermuxBridge" + + const val TERMUX_PACKAGE = "com.termux" + private const val RUN_COMMAND_SERVICE = "com.termux.app.RunCommandService" + private const val RUN_COMMAND_ACTION = "com.termux.RUN_COMMAND" + const val RUN_COMMAND_PERMISSION = "com.termux.permission.RUN_COMMAND" + + private const val EXTRA_COMMAND_PATH = "com.termux.RUN_COMMAND_PATH" + private const val EXTRA_ARGUMENTS = "com.termux.RUN_COMMAND_ARGUMENTS" + private const val EXTRA_WORKDIR = "com.termux.RUN_COMMAND_WORKDIR" + private const val EXTRA_BACKGROUND = "com.termux.RUN_COMMAND_BACKGROUND" + private const val EXTRA_SESSION_ACTION = "com.termux.RUN_COMMAND_SESSION_ACTION" + + const val FDROID_URL = "https://f-droid.org/packages/com.termux/" + const val GITHUB_URL = "https://github.com/termux/termux-app/releases" + + /** + * Check if Termux is installed on the device. + */ + fun isTermuxInstalled(context: Context): Boolean { + return try { + context.packageManager.getPackageInfo(TERMUX_PACKAGE, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + } + + /** + * Check if the app has the RUN_COMMAND permission for Termux. + */ + fun hasRunCommandPermission(context: Context): Boolean { + return context.checkSelfPermission(RUN_COMMAND_PERMISSION) == + PackageManager.PERMISSION_GRANTED + } + + /** + * Launch the Termux app. + */ + fun launchTermux(context: Context) { + val intent = context.packageManager.getLaunchIntentForPackage(TERMUX_PACKAGE) + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + Log.w(TAG, "Could not launch Termux — no launch intent found") + } + } + + /** + * Run a command in Termux via the RUN_COMMAND intent. + * + * @param context Android context + * @param command The executable path (e.g., "/data/data/com.termux/files/usr/bin/python") + * @param arguments Command arguments + * @param background If true, runs in background without a terminal session + * @param workdir Working directory for the command + */ + fun runCommand( + context: Context, + command: String, + arguments: Array = emptyArray(), + background: Boolean = true, + workdir: String? = null + ) { + val intent = Intent(RUN_COMMAND_ACTION).apply { + setClassName(TERMUX_PACKAGE, RUN_COMMAND_SERVICE) + putExtra(EXTRA_COMMAND_PATH, command) + putExtra(EXTRA_ARGUMENTS, arguments) + putExtra(EXTRA_BACKGROUND, background) + if (workdir != null) { + putExtra(EXTRA_WORKDIR, workdir) + } + // 0 = open new session, 1 = attach to current, 2 = do nothing + putExtra(EXTRA_SESSION_ACTION, "0") + } + try { + context.startForegroundService(intent) + Log.d(TAG, "Sent RUN_COMMAND to Termux: $command ${arguments.joinToString(" ")}") + } catch (e: Exception) { + Log.e(TAG, "Failed to send RUN_COMMAND to Termux", e) + } + } + + /** + * Install a pip package in Termux. + */ + fun pipInstall(context: Context, packageName: String) { + runCommand( + context = context, + command = "/data/data/com.termux/files/usr/bin/bash", + arguments = arrayOf("-c", "pip install $packageName"), + background = false + ) + } + + /** + * Start a Python-based MCP server in Termux. + * + * @param context Android context + * @param pipPackage The pip package name of the MCP server + * @param port The port to run the server on + * @param extraArgs Additional arguments for the server command + */ + fun startMcpServer( + context: Context, + pipPackage: String, + port: Int, + extraArgs: Array = emptyArray() + ) { + val serverCmd = buildString { + append("python -m $pipPackage --port $port") + if (extraArgs.isNotEmpty()) { + append(" ") + append(extraArgs.joinToString(" ")) + } + } + runCommand( + context = context, + command = "/data/data/com.termux/files/usr/bin/bash", + arguments = arrayOf("-c", serverCmd), + background = true + ) + } + + /** + * Get the localhost URL for a Termux-hosted MCP server. + */ + fun getLocalServerUrl(port: Int, path: String = "/sse"): String { + return "http://127.0.0.1:$port$path" + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt index 877efafe..824770e8 100644 --- a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpServersScreen.kt @@ -46,6 +46,7 @@ private val SuccessGreen = Color(0xFF4CAF50) @Composable fun McpServersScreen( onBackClick: () -> Unit, + onStoreClick: () -> Unit = {}, viewModel: McpServerViewModel = hiltViewModel() ) { val servers by viewModel.servers.collectAsStateWithLifecycle() @@ -85,6 +86,11 @@ fun McpServersScreen( ) }, actions = { + ActionButton( + onClickListener = onStoreClick, + icon = Icons.Default.Store, + modifier = Modifier.padding(end = rDp(4.dp)) + ) ActionButton( onClickListener = { viewModel.showAddServerDialog() }, icon = Icons.Default.Add, diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt new file mode 100644 index 00000000..5c1671e2 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt @@ -0,0 +1,434 @@ +package com.dark.tool_neuron.ui.screen + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.dark.tool_neuron.models.McpStoreCategories +import com.dark.tool_neuron.models.McpStoreEntry +import com.dark.tool_neuron.ui.components.ActionButton +import com.dark.tool_neuron.ui.components.ActionTextButton +import com.dark.tool_neuron.ui.theme.rDp +import com.dark.tool_neuron.viewmodel.McpStoreViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun McpStoreScreen( + onBackClick: () -> Unit, + viewModel: McpStoreViewModel = hiltViewModel() +) { + val entries by viewModel.filteredEntries.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val selectedCategory by viewModel.selectedCategory.collectAsStateWithLifecycle() + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val error by viewModel.error.collectAsStateWithLifecycle() + val installedIds by viewModel.installedIds.collectAsStateWithLifecycle() + val installMessage by viewModel.installMessage.collectAsStateWithLifecycle() + val showTermuxDialog by viewModel.showTermuxDialog.collectAsStateWithLifecycle() + val pendingTermuxEntry by viewModel.pendingTermuxEntry.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + "MCP Store", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + }, + navigationIcon = { + ActionTextButton( + onClickListener = onBackClick, + icon = Icons.Default.ChevronLeft, + text = "Back", + modifier = Modifier.padding(start = rDp(6.dp)) + ) + }, + actions = { + ActionButton( + onClickListener = { viewModel.refresh() }, + icon = Icons.Default.Refresh, + modifier = Modifier.padding(end = rDp(6.dp)) + ) + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + // Search bar + OutlinedTextField( + value = searchQuery, + onValueChange = { viewModel.setSearchQuery(it) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = rDp(16.dp), vertical = rDp(8.dp)), + placeholder = { Text("Search MCP servers...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { viewModel.setSearchQuery("") }) { + Icon(Icons.Default.Close, contentDescription = "Clear") + } + } + }, + singleLine = true, + shape = RoundedCornerShape(rDp(12.dp)) + ) + + // Category chips + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = rDp(12.dp)), + horizontalArrangement = Arrangement.spacedBy(rDp(8.dp)) + ) { + items(McpStoreCategories.all) { category -> + FilterChip( + selected = selectedCategory == category, + onClick = { viewModel.setSelectedCategory(category) }, + label = { Text(category) }, + leadingIcon = if (selectedCategory == category) { + { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } + } else null + ) + } + } + + Spacer(modifier = Modifier.height(rDp(8.dp))) + + // Content + Box(modifier = Modifier.fillMaxSize()) { + if (entries.isEmpty() && !isLoading) { + EmptyStoreState() + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = rDp(16.dp), vertical = rDp(8.dp)), + verticalArrangement = Arrangement.spacedBy(rDp(12.dp)) + ) { + items(entries, key = { it.id }) { entry -> + StoreEntryCard( + entry = entry, + isInstalled = entry.id in installedIds, + isTermuxAvailable = viewModel.isTermuxInstalled, + onInstall = { viewModel.installEntry(entry) } + ) + } + } + } + + // Loading overlay + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + // Error/install message snackbar + val message = error ?: installMessage + message?.let { msg -> + Snackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(rDp(16.dp)), + action = { + TextButton(onClick = { + viewModel.clearError() + viewModel.clearInstallMessage() + }) { + Text("Dismiss") + } + } + ) { + Text(msg) + } + } + } + } + } + + // Termux setup dialog + if (showTermuxDialog) { + TermuxSetupDialog( + entry = pendingTermuxEntry, + onDismiss = { viewModel.dismissTermuxDialog() }, + onDownloadTermux = { viewModel.openTermuxDownload(it) }, + onProceed = { viewModel.proceedWithTermuxInstall() }, + isTermuxInstalled = viewModel.isTermuxInstalled + ) + } +} + +@Composable +private fun StoreEntryCard( + entry: McpStoreEntry, + isInstalled: Boolean, + isTermuxAvailable: Boolean, + onInstall: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(rDp(12.dp)) + ) { + Column( + modifier = Modifier.padding(rDp(16.dp)) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(rDp(12.dp)), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon + Box( + modifier = Modifier + .size(rDp(40.dp)) + .clip(RoundedCornerShape(rDp(8.dp))) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = getIconForEntry(entry), + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(rDp(24.dp)) + ) + } + + Column { + Text( + text = entry.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = "by ${entry.author}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Install button + if (isInstalled) { + FilledTonalButton( + onClick = {}, + enabled = false + ) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Added") + } + } else { + Button(onClick = onInstall) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Install") + } + } + } + + Spacer(modifier = Modifier.height(rDp(8.dp))) + + Text( + text = entry.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(rDp(8.dp))) + + // Badges row + Row( + horizontalArrangement = Arrangement.spacedBy(rDp(6.dp)) + ) { + CategoryBadge(text = entry.category) + + if (entry.requiresApiKey) { + CategoryBadge(text = "API Key", color = MaterialTheme.colorScheme.tertiaryContainer) + } + + if (entry.requiresTermux) { + CategoryBadge( + text = if (isTermuxAvailable) "Termux" else "Termux Required", + color = if (isTermuxAvailable) + MaterialTheme.colorScheme.secondaryContainer + else + MaterialTheme.colorScheme.errorContainer + ) + } + + Text( + text = entry.transportType, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Setup instructions + entry.setupInstructions?.let { instructions -> + Spacer(modifier = Modifier.height(rDp(6.dp))) + Text( + text = instructions, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +@Composable +private fun CategoryBadge( + text: String, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.secondaryContainer +) { + Surface( + shape = RoundedCornerShape(rDp(4.dp)), + color = color + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = rDp(6.dp), vertical = rDp(2.dp)), + style = MaterialTheme.typography.labelSmall + ) + } +} + +@Composable +private fun EmptyStoreState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.Store, + contentDescription = null, + modifier = Modifier.size(rDp(64.dp)), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(rDp(16.dp))) + Text( + text = "No servers found", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Try a different search or category", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } +} + +@Composable +private fun TermuxSetupDialog( + entry: McpStoreEntry?, + onDismiss: () -> Unit, + onDownloadTermux: (android.content.Context) -> Unit, + onProceed: () -> Unit, + isTermuxInstalled: Boolean +) { + val context = androidx.compose.ui.platform.LocalContext.current + + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Terminal, contentDescription = null) }, + title = { Text("Termux Required") }, + text = { + Column { + if (!isTermuxInstalled) { + Text("This MCP server (${entry?.name ?: "unknown"}) runs locally on your device using Termux.") + Spacer(modifier = Modifier.height(8.dp)) + Text("Termux is a free terminal emulator that lets you run Python and other tools on Android.") + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Please install Termux from GitHub releases or F-Droid (not Play Store).", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold + ) + } else { + Text("Termux is installed. The pip package '${entry?.pipPackage ?: ""}' will be installed in Termux.") + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Make sure Python is installed in Termux (run: pkg install python)", + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + if (isTermuxInstalled) { + TextButton(onClick = onProceed) { + Text("Install") + } + } else { + TextButton(onClick = { onDownloadTermux(context) }) { + Text("Download Termux") + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +private fun getIconForEntry(entry: McpStoreEntry): ImageVector { + return when (entry.iconName) { + "Search" -> Icons.Default.Search + "Code" -> Icons.Default.Code + "Language" -> Icons.Default.Language + "Folder" -> Icons.Default.Folder + "Storage" -> Icons.Default.Storage + "Psychology" -> Icons.Default.Psychology + "Science" -> Icons.Default.Science + "Cloud" -> Icons.Default.Cloud + "OndemandVideo" -> Icons.Default.OndemandVideo + else -> Icons.Default.Extension + } +} diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/McpStoreViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpStoreViewModel.kt new file mode 100644 index 00000000..4f55dc21 --- /dev/null +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/McpStoreViewModel.kt @@ -0,0 +1,177 @@ +package com.dark.tool_neuron.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.dark.tool_neuron.models.McpStoreCategories +import com.dark.tool_neuron.models.McpStoreEntry +import com.dark.tool_neuron.models.table_schema.McpServer +import com.dark.tool_neuron.models.table_schema.McpTransportType +import com.dark.tool_neuron.repo.McpServerRepository +import com.dark.tool_neuron.repo.McpStoreRepository +import com.dark.tool_neuron.service.TermuxBridge +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class McpStoreViewModel @Inject constructor( + @ApplicationContext private val appContext: Context, + private val storeRepository: McpStoreRepository, + private val mcpServerRepository: McpServerRepository +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _selectedCategory = MutableStateFlow(McpStoreCategories.ALL) + val selectedCategory: StateFlow = _selectedCategory.asStateFlow() + + private val _installedIds = MutableStateFlow>(emptySet()) + + private val _installMessage = MutableStateFlow(null) + val installMessage: StateFlow = _installMessage.asStateFlow() + + private val _showTermuxDialog = MutableStateFlow(false) + val showTermuxDialog: StateFlow = _showTermuxDialog.asStateFlow() + + private val _pendingTermuxEntry = MutableStateFlow(null) + val pendingTermuxEntry: StateFlow = _pendingTermuxEntry.asStateFlow() + + val isLoading = storeRepository.isLoading + val error = storeRepository.error + + val filteredEntries: StateFlow> = combine( + storeRepository.entries, + _searchQuery, + _selectedCategory + ) { entries, query, category -> + storeRepository.filterEntries(entries, category, query) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val installedIds: StateFlow> = _installedIds.asStateFlow() + + val isTermuxInstalled: Boolean + get() = TermuxBridge.isTermuxInstalled(appContext) + + init { + viewModelScope.launch { + storeRepository.loadEntries() + refreshInstalledIds() + } + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun setSelectedCategory(category: String) { + _selectedCategory.value = category + } + + fun refresh() { + viewModelScope.launch { + storeRepository.refresh() + refreshInstalledIds() + } + } + + fun clearError() { + storeRepository.clearError() + } + + fun clearInstallMessage() { + _installMessage.value = null + } + + fun dismissTermuxDialog() { + _showTermuxDialog.value = false + _pendingTermuxEntry.value = null + } + + /** + * Install an MCP store entry as a local McpServer configuration. + */ + fun installEntry(entry: McpStoreEntry) { + if (entry.requiresTermux && !TermuxBridge.isTermuxInstalled(appContext)) { + _pendingTermuxEntry.value = entry + _showTermuxDialog.value = true + return + } + + viewModelScope.launch { + try { + val url = if (entry.requiresTermux && entry.defaultPort != null) { + TermuxBridge.getLocalServerUrl(entry.defaultPort) + } else { + entry.url + } + + val transportType = try { + McpTransportType.valueOf(entry.transportType) + } catch (e: Exception) { + McpTransportType.SSE + } + + val server = McpServer( + id = McpServer.generateId(), + name = entry.name, + url = url, + transportType = transportType, + isEnabled = !entry.requiresTermux, + description = entry.description, + isLocal = entry.requiresTermux, + sourceStoreId = entry.id + ) + mcpServerRepository.addServerDirect(server) + _installedIds.value = _installedIds.value + entry.id + + if (entry.requiresTermux && entry.pipPackage != null) { + TermuxBridge.pipInstall(appContext, entry.pipPackage) + _installMessage.value = "Installed ${entry.name}. Installing pip package in Termux..." + } else { + _installMessage.value = "${entry.name} added to your MCP servers" + } + } catch (e: Exception) { + _installMessage.value = "Failed to install ${entry.name}: ${e.message}" + } + } + } + + /** + * Proceed with Termux entry installation after user acknowledges the dialog. + */ + fun proceedWithTermuxInstall() { + val entry = _pendingTermuxEntry.value ?: return + _showTermuxDialog.value = false + _pendingTermuxEntry.value = null + if (TermuxBridge.isTermuxInstalled(appContext)) { + installEntry(entry) + } + } + + fun openTermuxDownload(context: Context) { + try { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(TermuxBridge.GITHUB_URL) + ) + intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } catch (e: Exception) { + _installMessage.value = "Could not open browser. Visit ${TermuxBridge.GITHUB_URL}" + } + } + + private suspend fun refreshInstalledIds() { + val servers = mcpServerRepository.getAllServersSnapshot() + _installedIds.value = servers.mapNotNull { it.sourceStoreId }.toSet() + } +} From 1a0dc0d636aa4358637152d6292eaeb84f96a992 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:23:56 +0000 Subject: [PATCH 22/27] Initial plan From 6b6833041337bbaf62ec6922c1644171b69da37d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:41:09 +0000 Subject: [PATCH 23/27] Add task token MCP prompt guidance Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/ui/screen/McpStoreScreen.kt | 2 +- .../tool_neuron/viewmodel/ChatViewModel.kt | 48 +++++++++++++++---- .../viewmodel/ChatViewModelPromptTest.kt | 41 ++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt diff --git a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt index 5c1671e2..bf21213d 100644 --- a/app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt +++ b/app/src/main/java/com/dark/tool_neuron/ui/screen/McpStoreScreen.kt @@ -141,7 +141,7 @@ fun McpStoreScreen( } // Loading overlay - AnimatedVisibility( + androidx.compose.animation.AnimatedVisibility( visible = isLoading, enter = fadeIn(), exit = fadeOut() diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt index af53fc40..670327ba 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt @@ -563,12 +563,10 @@ class ChatViewModel @Inject constructor( private suspend fun generatePlan(prompt: String): String { PluginManager.clearGrammar() val toolDescriptions = PluginManager.getToolDescriptionsText() - val systemPrompt = buildString { - appendLine("Available tools:") - appendLine(toolDescriptions) - appendLine() - appendLine("Write a 1-2 sentence plan: which tools to call and what arguments to pass. Be specific and concise.") - } + val systemPrompt = buildAgentPlanningPrompt( + toolDescriptions = toolDescriptions, + hasMcpTools = mcpToolRegistry.isNotEmpty() + ) val messages = listOf( JSONObject().put("role", "system").put("content", systemPrompt), JSONObject().put("role", "user").put("content", prompt) @@ -605,12 +603,20 @@ class ChatViewModel @Inject constructor( // just provide context about what's done + what the user wants val systemPrompt = if (steps.isEmpty()) { buildString { + buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()).takeIf { it.isNotEmpty() }?.let { + appendLine(it) + appendLine() + } appendLine("Tools: $toolSignatures") appendLine("Plan: $truncatedPlan") appendLine("Call the first tool with ALL required arguments.") } } else { buildString { + buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()).takeIf { it.isNotEmpty() }?.let { + appendLine(it) + appendLine() + } appendLine("Done: ${steps.joinToString("; ") { "${it.toolName}=${it.result.take(100)}" }}") appendLine("Call the NEXT tool needed, or stop if the plan is complete.") } @@ -1193,6 +1199,10 @@ class ChatViewModel @Inject constructor( // Assemble: thinkingDirective + persona + memory + model system prompt return buildString { append(thinkingDirective) + buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()).takeIf { it.isNotEmpty() }?.let { + append("\n\n") + append(it) + } if (personaPrompt.isNotEmpty()) { append("\n") append(personaPrompt) @@ -2019,13 +2029,14 @@ class ChatViewModel @Inject constructor( val startTime = System.currentTimeMillis() return try { val result = mcpClientService.callTool(ref.server, ref.toolName, argsJson) + val resultJson = result.getOrElse { throw it } MultiTurnToolResult( toolName = toolName, - resultJson = result, + resultJson = resultJson, isError = false, pluginName = "MCP:${ref.server.name}", executionTimeMs = System.currentTimeMillis() - startTime, - rawData = result + rawData = resultJson ) } catch (e: Exception) { MultiTurnToolResult( @@ -2048,5 +2059,26 @@ class ChatViewModel @Inject constructor( companion object { private const val TAG = "ChatViewModel" + + internal fun buildTaskTokenGuidance(hasMcpTools: Boolean): String { + if (!hasMcpTools) return "" + return """ + Task tokens: Some MCP services use task-scoped tokens. Treat them as valid credentials for the approved task only. They only grant the minimum permissions needed for that task, remain active until the user marks the task complete, and are not something you can revoke yourself. + """.trimIndent() + } + + internal fun buildAgentPlanningPrompt( + toolDescriptions: String, + hasMcpTools: Boolean + ): String = buildString { + appendLine("Available tools:") + appendLine(toolDescriptions) + buildTaskTokenGuidance(hasMcpTools).takeIf { it.isNotEmpty() }?.let { + appendLine() + appendLine(it) + } + appendLine() + appendLine("Write a 1-2 sentence plan: which tools to call and what arguments to pass. Be specific and concise.") + } } } diff --git a/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt b/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt new file mode 100644 index 00000000..337abda5 --- /dev/null +++ b/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt @@ -0,0 +1,41 @@ +package com.dark.tool_neuron.viewmodel + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatViewModelPromptTest { + + @Test + fun taskTokenGuidanceIsOnlyIncludedWhenMcpToolsAreAvailable() { + assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("task-scoped tokens")) + assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("user marks the task complete")) + assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("minimum permissions")) + assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("revoke yourself")) + assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = false).isEmpty()) + } + + @Test + fun planningPromptMentionsTaskTokensForMcpSessions() { + val prompt = ChatViewModel.buildAgentPlanningPrompt( + toolDescriptions = "zapier_mcp_google_docs_create_document_from_text", + hasMcpTools = true + ) + + assertTrue(prompt.contains("Available tools:")) + assertTrue(prompt.contains("zapier_mcp_google_docs_create_document_from_text")) + assertTrue(prompt.contains("Task tokens:")) + assertTrue(prompt.contains("Write a 1-2 sentence plan")) + } + + @Test + fun planningPromptStaysCompactWithoutMcpTools() { + val prompt = ChatViewModel.buildAgentPlanningPrompt( + toolDescriptions = "web_search", + hasMcpTools = false + ) + + assertTrue(prompt.contains("web_search")) + assertFalse(prompt.contains("Task tokens:")) + } +} From 92d15d198dc740ecbb850f5a06981bbe921730bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:43:46 +0000 Subject: [PATCH 24/27] Refine task token prompt tests Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt | 3 ++- .../dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt index 670327ba..7b1bc7c0 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt @@ -2059,11 +2059,12 @@ class ChatViewModel @Inject constructor( companion object { private const val TAG = "ChatViewModel" + internal const val TASK_TOKEN_GUIDANCE_HEADING = "Task tokens:" internal fun buildTaskTokenGuidance(hasMcpTools: Boolean): String { if (!hasMcpTools) return "" return """ - Task tokens: Some MCP services use task-scoped tokens. Treat them as valid credentials for the approved task only. They only grant the minimum permissions needed for that task, remain active until the user marks the task complete, and are not something you can revoke yourself. + $TASK_TOKEN_GUIDANCE_HEADING Some MCP services use task-scoped tokens. Treat them as valid credentials for the current task only. They only grant the minimum permissions needed for that task, remain active until the user marks the task complete, and are not something you can revoke yourself. """.trimIndent() } diff --git a/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt b/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt index 337abda5..b1cdc0aa 100644 --- a/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt +++ b/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt @@ -10,7 +10,7 @@ class ChatViewModelPromptTest { fun taskTokenGuidanceIsOnlyIncludedWhenMcpToolsAreAvailable() { assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("task-scoped tokens")) assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("user marks the task complete")) - assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("minimum permissions")) + assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("minimum permissions needed")) assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("revoke yourself")) assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = false).isEmpty()) } @@ -24,7 +24,9 @@ class ChatViewModelPromptTest { assertTrue(prompt.contains("Available tools:")) assertTrue(prompt.contains("zapier_mcp_google_docs_create_document_from_text")) - assertTrue(prompt.contains("Task tokens:")) + assertTrue(prompt.contains(ChatViewModel.TASK_TOKEN_GUIDANCE_HEADING)) + assertTrue(prompt.contains("task-scoped tokens")) + assertTrue(prompt.contains("user marks the task complete")) assertTrue(prompt.contains("Write a 1-2 sentence plan")) } @@ -36,6 +38,6 @@ class ChatViewModelPromptTest { ) assertTrue(prompt.contains("web_search")) - assertFalse(prompt.contains("Task tokens:")) + assertFalse(prompt.contains(ChatViewModel.TASK_TOKEN_GUIDANCE_HEADING)) } } From 3793da4a89a14a98dea2c7d84d0ca491ec1cf99e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:45:05 +0000 Subject: [PATCH 25/27] Simplify task token guidance wiring Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../tool_neuron/viewmodel/ChatViewModel.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt index 7b1bc7c0..c266e5d5 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt @@ -594,6 +594,7 @@ class ChatViewModel @Inject constructor( val enabledNames = PluginManager.getEnabledToolNames().map { it.lowercase() } + mcpToolRegistry.keys.map { it.lowercase() } val truncatedPlan = plan.take(200) + val taskTokenGuidance = buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()) for (round in 1..maxRounds) { // Generate next tool call @@ -603,8 +604,8 @@ class ChatViewModel @Inject constructor( // just provide context about what's done + what the user wants val systemPrompt = if (steps.isEmpty()) { buildString { - buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()).takeIf { it.isNotEmpty() }?.let { - appendLine(it) + if (taskTokenGuidance.isNotEmpty()) { + appendLine(taskTokenGuidance) appendLine() } appendLine("Tools: $toolSignatures") @@ -613,8 +614,8 @@ class ChatViewModel @Inject constructor( } } else { buildString { - buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()).takeIf { it.isNotEmpty() }?.let { - appendLine(it) + if (taskTokenGuidance.isNotEmpty()) { + appendLine(taskTokenGuidance) appendLine() } appendLine("Done: ${steps.joinToString("; ") { "${it.toolName}=${it.result.take(100)}" }}") @@ -1197,11 +1198,12 @@ class ChatViewModel @Inject constructor( } else "" // Assemble: thinkingDirective + persona + memory + model system prompt + val taskTokenGuidance = buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()) return buildString { append(thinkingDirective) - buildTaskTokenGuidance(hasMcpTools = mcpToolRegistry.isNotEmpty()).takeIf { it.isNotEmpty() }?.let { + if (taskTokenGuidance.isNotEmpty()) { append("\n\n") - append(it) + append(taskTokenGuidance) } if (personaPrompt.isNotEmpty()) { append("\n") @@ -2074,9 +2076,10 @@ class ChatViewModel @Inject constructor( ): String = buildString { appendLine("Available tools:") appendLine(toolDescriptions) - buildTaskTokenGuidance(hasMcpTools).takeIf { it.isNotEmpty() }?.let { + val taskTokenGuidance = buildTaskTokenGuidance(hasMcpTools) + if (taskTokenGuidance.isNotEmpty()) { appendLine() - appendLine(it) + appendLine(taskTokenGuidance) } appendLine() appendLine("Write a 1-2 sentence plan: which tools to call and what arguments to pass. Be specific and concise.") From efac9513df5347642b2bae9f7b865186992d9837 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:47:51 +0000 Subject: [PATCH 26/27] Finalize task token prompt wording Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt | 6 +++++- .../dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt index c266e5d5..1823452f 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt @@ -2066,7 +2066,11 @@ class ChatViewModel @Inject constructor( internal fun buildTaskTokenGuidance(hasMcpTools: Boolean): String { if (!hasMcpTools) return "" return """ - $TASK_TOKEN_GUIDANCE_HEADING Some MCP services use task-scoped tokens. Treat them as valid credentials for the current task only. They only grant the minimum permissions needed for that task, remain active until the user marks the task complete, and are not something you can revoke yourself. + $TASK_TOKEN_GUIDANCE_HEADING Some MCP services use task-scoped tokens. + Treat them as valid credentials for the current task only. + They only grant the minimum permissions needed for that task, + remain active until the user marks the task complete. + You cannot revoke them yourself. """.trimIndent() } diff --git a/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt b/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt index b1cdc0aa..83f04312 100644 --- a/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt +++ b/app/src/test/java/com/dark/tool_neuron/viewmodel/ChatViewModelPromptTest.kt @@ -11,7 +11,7 @@ class ChatViewModelPromptTest { assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("task-scoped tokens")) assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("user marks the task complete")) assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("minimum permissions needed")) - assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("revoke yourself")) + assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = true).contains("You cannot revoke them yourself")) assertTrue(ChatViewModel.buildTaskTokenGuidance(hasMcpTools = false).isEmpty()) } From 30569d7cbbc3dad4f2e89404b1a683eb4dabc0df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:48:51 +0000 Subject: [PATCH 27/27] Fix final task token prompt grammar Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt index 1823452f..fbb542c0 100644 --- a/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/dark/tool_neuron/viewmodel/ChatViewModel.kt @@ -2068,8 +2068,8 @@ class ChatViewModel @Inject constructor( return """ $TASK_TOKEN_GUIDANCE_HEADING Some MCP services use task-scoped tokens. Treat them as valid credentials for the current task only. - They only grant the minimum permissions needed for that task, - remain active until the user marks the task complete. + They only grant the minimum permissions needed for that task + and remain active until the user marks the task complete. You cannot revoke them yourself. """.trimIndent() }