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/.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/build.gradle.kts b/app/build.gradle.kts
index 4fb3af7c..34eb0852 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -152,6 +152,10 @@ dependencies {
// Debug
debugImplementation(libs.androidx.compose.ui.tooling)
+
+ // Tests
+ testImplementation(libs.junit)
+ testImplementation(libs.org.json)
}
fun getProperty(value: String): String {
@@ -163,4 +167,4 @@ fun getProperty(value: String): String {
} else {
System.getenv(value) ?: "\"sample_val\""
}
-}
\ No newline at end of file
+}
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 @@
+
+
+
+
+
Screen.Terms.route
!termsAccepted -> Screen.Welcome.route
-
- // Terms accepted but setup not done and no models: go to setup
!setupDone && !hasModel -> Screen.OnboardingSetup.route
-
- // Everything done
else -> Screen.Chat.route
}
}
@@ -122,7 +119,6 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
super.onDestroy()
- // Clear password cache when app terminates
ragRepository.clearPasswordCache()
LlmModelWorker.unbindService()
AppContainer.shutdown()
@@ -130,7 +126,7 @@ class MainActivity : ComponentActivity() {
}
sealed class Screen(val route: String) {
- // Onboarding (flat routes so any can be used as startDestination)
+ // Onboarding
object Welcome : Screen("welcome")
object Terms : Screen("terms")
object OnboardingSetup : Screen("setup")
@@ -141,6 +137,8 @@ sealed class Screen(val route: String) {
object Editor : Screen("editor")
object Settings : Screen("settings")
object VaultManager : Screen("vault_manager")
+ object McpServers : Screen("mcp_servers")
+ object McpStore : Screen("mcp_store")
object Personas : Screen("personas")
object PersonaEditor : Screen("persona_editor/{personaId}") {
fun createRoute(personaId: String? = null) = "persona_editor/${personaId ?: "new"}"
@@ -157,7 +155,6 @@ fun AppNavigation(
val scope = rememberCoroutineScope()
val navController = rememberNavController()
- // Activity-scoped ViewModels for shared state between Chat and Personas
val chatViewModel: ChatViewModel = hiltViewModel()
val llmModelViewModel: LLMModelViewModel = hiltViewModel()
@@ -207,12 +204,10 @@ fun AppNavigation(
termsDataStore.acceptTerms()
}
if (hasModelsInstalled) {
- // Returning user: skip setup, go to chat
navController.navigate(Screen.Chat.route) {
popUpTo(0) { inclusive = true }
}
} else {
- // New user: proceed to setup
navController.navigate(Screen.OnboardingSetup.route)
}
}
@@ -241,6 +236,9 @@ fun AppNavigation(
onVaultManagerClick = {
navController.navigate(Screen.VaultManager.route)
},
+ onMcpServersClick = {
+ navController.navigate(Screen.McpServers.route)
+ },
onCharacterClick = {
navController.navigate(Screen.Personas.route)
},
@@ -304,7 +302,6 @@ fun AppNavigation(
personaId = personaId,
onNavigateBack = { navController.popBackStack() },
onDeleted = {
- // If deleted persona was active, clear selection
if (personaId != null && chatViewModel.activePersona.value?.id == personaId) {
chatViewModel.setActivePersona(null)
}
@@ -318,5 +315,16 @@ fun AppNavigation(
onNavigateBack = { navController.popBackStack() }
)
}
+
+ composable(Screen.McpServers.route) {
+ McpServersScreen(
+ onBackClick = { navController.popBackStack() },
+ onStoreClick = { navController.navigate(Screen.McpStore.route) }
+ )
+ }
+
+ composable(Screen.McpStore.route) {
+ McpStoreScreen(onBackClick = { navController.popBackStack() })
+ }
}
}
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 22caea0e..0f1e7c41 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
@@ -8,6 +8,7 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.dark.tool_neuron.database.dao.AiMemoryDao
+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.PersonaDao
@@ -15,14 +16,15 @@ import com.dark.tool_neuron.database.dao.RagDao
import com.dark.tool_neuron.models.converters.Converters
import com.dark.tool_neuron.models.table_schema.AiMemory
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
import com.dark.tool_neuron.models.table_schema.Persona
import java.util.UUID
@Database(
- entities = [Model::class, ModelConfig::class, InstalledRag::class, Persona::class, AiMemory::class],
- version = 6,
+ entities = [Model::class, ModelConfig::class, InstalledRag::class, McpServer::class, Persona::class, AiMemory::class],
+ version = 7,
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -30,6 +32,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun modelDao(): ModelDao
abstract fun modelConfigDao(): ModelConfigDao
abstract fun ragDao(): RagDao
+ abstract fun mcpServerDao(): McpServerDao
abstract fun personaDao(): PersonaDao
abstract fun aiMemoryDao(): AiMemoryDao
@@ -67,7 +70,6 @@ abstract class AppDatabase : RoomDatabase() {
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
- // Add missing columns to installed_rags table
db.execSQL("ALTER TABLE installed_rags ADD COLUMN is_encrypted INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE installed_rags ADD COLUMN loading_mode INTEGER NOT NULL DEFAULT 1")
db.execSQL("ALTER TABLE installed_rags ADD COLUMN has_admin_access INTEGER NOT NULL DEFAULT 0")
@@ -76,10 +78,6 @@ abstract class AppDatabase : RoomDatabase() {
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
- // Recreate installed_rags table without DEFAULT constraints in SQL
- // Room expects defaults to be handled at the application level, not database level
-
- // Create new table with correct schema (no DEFAULT clauses)
db.execSQL("""
CREATE TABLE IF NOT EXISTS installed_rags_new (
id TEXT PRIMARY KEY NOT NULL,
@@ -107,16 +105,12 @@ abstract class AppDatabase : RoomDatabase() {
)
""".trimIndent())
- // Copy data from old table to new table
db.execSQL("""
INSERT INTO installed_rags_new
SELECT * FROM installed_rags
""".trimIndent())
- // Drop old table
db.execSQL("DROP TABLE installed_rags")
-
- // Rename new table to original name
db.execSQL("ALTER TABLE installed_rags_new RENAME TO installed_rags")
}
}
@@ -151,10 +145,7 @@ abstract class AppDatabase : RoomDatabase() {
)
""".trimIndent())
- // Index on ai_memories.category
db.execSQL("CREATE INDEX IF NOT EXISTS index_ai_memories_category ON ai_memories (category)")
-
- // Seed default personas (v5 schema — no character-card columns yet)
seedDefaultPersonasV5(db)
}
}
@@ -170,15 +161,34 @@ abstract class AppDatabase : RoomDatabase() {
db.execSQL("ALTER TABLE personas ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
db.execSQL("ALTER TABLE personas ADD COLUMN avatar_uri TEXT")
db.execSQL("ALTER TABLE personas ADD COLUMN creator_notes TEXT NOT NULL DEFAULT ''")
- // Migrate legacy systemPrompt into description
db.execSQL("UPDATE personas SET description = system_prompt WHERE system_prompt != '' AND description = ''")
}
}
- /**
- * v5 schema seed — only the 7 original columns. Used by MIGRATION_4_5
- * where the v6 character-card columns don't exist yet.
- */
+ private val MIGRATION_6_7 = object : Migration(6, 7) {
+ 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,
+ isLocal INTEGER NOT NULL DEFAULT 0,
+ sourceStoreId TEXT
+ )
+ """.trimIndent())
+ }
+ }
+
private fun seedDefaultPersonasV5(db: SupportSQLiteDatabase) {
val now = System.currentTimeMillis()
val cols = "id, name, avatar, system_prompt, greeting, is_default, created_at"
@@ -213,10 +223,6 @@ abstract class AppDatabase : RoomDatabase() {
)
}
- /**
- * Full v6 schema seed — includes character-card columns. Used by onCreate
- * where Room creates the complete schema (all NOT NULL columns present).
- */
private fun seedDefaultPersonas(db: SupportSQLiteDatabase) {
val now = System.currentTimeMillis()
val cols = "id, name, avatar, system_prompt, greeting, is_default, created_at, description, personality, scenario, example_messages, alternate_greetings, tags, creator_notes"
@@ -259,7 +265,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java,
"llm_models_database"
)
- .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7)
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
@@ -273,4 +279,4 @@ abstract class AppDatabase : RoomDatabase() {
}
}
}
-}
\ No newline at end of file
+}
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..e33e7079
--- /dev/null
+++ b/app/src/main/java/com/dark/tool_neuron/database/dao/McpServerDao.kt
@@ -0,0 +1,45 @@
+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)
+
+ @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
+
+ @Query("SELECT COUNT(*) FROM mcp_servers WHERE isEnabled = 1")
+ fun getEnabledServerCount(): Flow
+
+ @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/AppContainer.kt b/app/src/main/java/com/dark/tool_neuron/di/AppContainer.kt
index 4c3d16d6..99c445d4 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
@@ -6,7 +6,9 @@ import com.dark.tool_neuron.database.AppDatabase
import com.dark.tool_neuron.database.dao.AiMemoryDao
import com.dark.tool_neuron.database.dao.PersonaDao
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
@@ -23,6 +25,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
@@ -43,6 +47,8 @@ object AppContainer {
)
chatRepository = ChatRepository()
+ mcpServerRepository = McpServerRepository(database.mcpServerDao())
+ mcpClientService = McpClientService()
llmModelViewModelFactory = LLMModelViewModelFactory(application, modelRepository)
chatListViewModelFactory = ChatListViewModelFactory(chatManager)
@@ -94,6 +100,10 @@ object AppContainer {
fun getChatRepository(): ChatRepository = chatRepository
+ fun getMcpServerRepository(): McpServerRepository = mcpServerRepository
+
+ fun getMcpClientService(): McpClientService = mcpClientService
+
fun getLLMModelViewModelFactory(): LLMModelViewModelFactory = llmModelViewModelFactory
fun getChatListViewModelFactory(): ChatListViewModelFactory = chatListViewModelFactory
@@ -113,4 +123,4 @@ object AppContainer {
fun getAiMemoryDao(): AiMemoryDao = database.aiMemoryDao()
fun getGenerationManager(): GenerationManager = generationManager
-}
\ No newline at end of file
+}
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..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
@@ -4,8 +4,11 @@
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.McpStoreRepository
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 +68,26 @@
context = context
)
}
+
+ @Provides
+ @Singleton
+ fun provideMcpServerRepository(database: AppDatabase): McpServerRepository {
+ return McpServerRepository(
+ mcpServerDao = database.mcpServerDao()
+ )
+ }
+
+ @Provides
+ @Singleton
+ fun provideMcpStoreRepository(
+ @ApplicationContext context: Context,
+ mcpServerRepository: McpServerRepository
+ ): McpStoreRepository {
+ return McpStoreRepository(
+ context = context,
+ mcpServerRepository = mcpServerRepository
+ )
+ }
}
@Module
@@ -78,6 +101,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/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/converters/Converters.kt b/app/src/main/java/com/dark/tool_neuron/models/converters/Converters.kt
index 1ed4e662..95958658 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,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
import org.json.JSONArray
class Converters {
@@ -18,6 +19,12 @@ 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 fromStringList(value: List): String = JSONArray(value).toString()
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..3fed8ce6
--- /dev/null
+++ b/app/src/main/java/com/dark/tool_neuron/models/table_schema/McpServer.kt
@@ -0,0 +1,75 @@
+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,
+
+ /** Whether the server is enabled */
+ val isEnabled: Boolean = true,
+
+ /** 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,
+
+ /** 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
new file mode 100644
index 00000000..c1eefb0d
--- /dev/null
+++ b/app/src/main/java/com/dark/tool_neuron/repo/McpServerRepository.kt
@@ -0,0 +1,165 @@
+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
+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 java.net.URI
+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
+) {
+ companion object {
+ private const val TAG = "McpServerRepository"
+ }
+ // Runtime connection status tracking (not persisted)
+ private val _connectionStatuses = MutableStateFlow