Skip to content
Merged
35 changes: 35 additions & 0 deletions .github/workflows/build-debug-apk.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions app/src/main/java/com/dark/tool_neuron/activity/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -176,6 +178,9 @@ fun AppNavigation(
onVaultManagerClick = {
navController.navigate(Screen.VaultManager.route)
},
onMcpServersClick = {
navController.navigate(Screen.McpServers.route)
},
chatViewModel = chatViewModel,
llmModelViewModel = llmModelViewModel
)
Expand All @@ -196,5 +201,11 @@ fun AppNavigation(
composable(Screen.VaultManager.route) {
VaultDashboard()
}

composable(Screen.McpServers.route) {
McpServersScreen(onBackClick = {
navController.popBackStack()
})
}
}
}
31 changes: 28 additions & 3 deletions app/src/main/java/com/dark/tool_neuron/database/AppDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,27 @@ 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)
abstract class AppDatabase : RoomDatabase() {
abstract fun modelDao(): ModelDao
abstract fun modelConfigDao(): ModelConfigDao
abstract fun ragDao(): RagDao
abstract fun mcpServerDao(): McpServerDao

companion object {
@Volatile
Expand Down Expand Up @@ -114,14 +117,36 @@ 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,
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(
context.applicationContext,
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using fallbackToDestructiveMigration() can be risky in a production application as it will delete the database and all its data if a migration is missing, leading to unexpected data loss for users. For a production-ready app, it's safer to provide all necessary migrations and remove this fallback.

.build()
INSTANCE = instance
Expand Down
Original file line number Diff line number Diff line change
@@ -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<List<McpServer>>

@Query("SELECT * FROM mcp_servers WHERE isEnabled = 1 ORDER BY name ASC")
fun getEnabledServers(): Flow<List<McpServer>>

@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)

@Query("UPDATE mcp_servers SET lastConnectedAt = :timestamp, updatedAt = :updatedAt WHERE id = :id")
suspend fun updateLastConnected(id: String, timestamp: Long, updatedAt: Long)

@Query("SELECT COUNT(*) FROM mcp_servers")
fun getServerCount(): Flow<Int>

@Query("SELECT COUNT(*) FROM mcp_servers WHERE isEnabled = 1")
fun getEnabledServerCount(): Flow<Int>
}
21 changes: 21 additions & 0 deletions app/src/main/java/com/dark/tool_neuron/di/HiltModules.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -65,6 +67,14 @@
context = context
)
}

@Provides
@Singleton
fun provideMcpServerRepository(database: AppDatabase): McpServerRepository {
return McpServerRepository(
mcpServerDao = database.mcpServerDao()
)
}
}

@Module
Expand All @@ -78,6 +88,17 @@
}
}

@Module
@InstallIn(SingletonComponent::class)
object ServiceModule {

@Provides
@Singleton
fun provideMcpClientService(): McpClientService {
return McpClientService()
}
}

@Module
@InstallIn(SingletonComponent::class)
object WorkerModule {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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.McpTransportType

class Converters {
@TypeConverter
Expand All @@ -16,4 +17,10 @@ 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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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 (runtime only, not persisted)
*/
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,
Comment on lines +42 to +43

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API key is stored in the database without encryption. Sensitive credentials like API keys should be encrypted at rest using Android's EncryptedSharedPreferences or similar encryption mechanisms to protect them from unauthorized access.

Copilot uses AI. Check for mistakes.

/** Whether the server is enabled */
val isEnabled: Boolean = true,

/** Last error message if connection failed */
val lastError: String? = null,

Comment on lines +48 to +50

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lastError field in McpServer is defined but never actually used anywhere in the repository or service layer. Consider either removing this unused field or implementing proper error persistence when connection attempts fail.

Suggested change
/** Last error message if connection failed */
val lastError: String? = null,

Copilot uses AI. Check for mistakes.
/** Timestamp when the server was added */
val createdAt: Long = System.currentTimeMillis(),

/** Timestamp when the server was last modified */
val updatedAt: Long = System.currentTimeMillis(),
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using System.currentTimeMillis() as a default value for createdAt and updatedAt in a data class can be problematic. Every time an McpServer object is copied without explicitly providing these values, they will be reset to the current time. It's safer to remove these defaults from the data class and set them explicitly only when creating or updating an entity in the repository. This prevents accidental timestamp changes.

Suggested change
val createdAt: Long = System.currentTimeMillis(),
/** Timestamp when the server was last modified */
val updatedAt: Long = System.currentTimeMillis(),
/** Timestamp when the server was added */
val createdAt: Long,
/** Timestamp when the server was last modified */
val updatedAt: Long,


/** 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
Comment on lines +61 to +64

Copilot AI Jan 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The customHeadersJson field is defined but not exposed in the UI or used in the service layer. Either implement support for custom headers in the HTTP requests or remove this unused field to keep the data model simple and maintainable.

Suggested change
val description: String = "",
/** Custom headers as JSON string (e.g., for additional auth) */
val customHeadersJson: String? = null
val description: String = ""

Copilot uses AI. Check for mistakes.
) {
companion object {
fun generateId(): String = java.util.UUID.randomUUID().toString()
}
}
Loading