Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ android {
versionCode = 1
versionName = "0.1.0"

// Load API key from local.properties for development
// This provides a default value that can be overridden at runtime
// Load API keys from local.properties for development
// These provide default values that can be overridden at runtime
val localProperties = gradleLocalProperties(rootDir, providers)
val dangerousOpenAiKey = localProperties.getProperty("DANGEROUS_OPENAI_API_KEY") ?: ""
val dangerousGithubToken = localProperties.getProperty("DANGEROUS_GITHUB_TOKEN") ?: ""
buildConfigField("String", "DANGEROUS_OPENAI_API_KEY", "\"$dangerousOpenAiKey\"")
buildConfigField("String", "DANGEROUS_GITHUB_TOKEN", "\"$dangerousGithubToken\"")
}

buildTypes {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,13 @@ class MainActivity : ComponentActivity() {
val audioCaptureService = AndroidAudioCaptureService(this, scope)
audioCaptureService.realtimeClient = realtimeClient // Wire up for PTT control

val logger = llc.lookatwhataicando.codeoba.core.domain.createLogger()

codeobaApp = CodeobaApp(
audioCaptureService = audioCaptureService,
audioRouteManager = AndroidAudioRouteManager(this),
realtimeClient = realtimeClient,
mcpClient = McpClientImpl(),
mcpClient = createMcpClient(logger),
companionProxy = CompanionProxyStub(),
scope = scope
)
Expand Down Expand Up @@ -186,6 +188,42 @@ class MainActivity : ComponentActivity() {
}
}

/**
* Creates MCP client with GitHub token if configured, otherwise returns a stub.
*/
private fun createMcpClient(logger: llc.lookatwhataicando.codeoba.core.domain.Logger): llc.lookatwhataicando.codeoba.core.domain.McpClient {
val githubToken = getGithubToken()
return if (githubToken != null) {
logger.i("MCP Client", "Initializing with GitHub token")
McpClientImpl(
githubToken = githubToken,
logger = logger
)
} else {
logger.w("MCP Client", "No GitHub token configured, MCP features disabled")
// Return stub implementation that doesn't connect
object : llc.lookatwhataicando.codeoba.core.domain.McpClient {
override suspend fun connect() {
// No-op for stub
}

override suspend fun handleToolCall(name: String, argsJson: String) =
llc.lookatwhataicando.codeoba.core.domain.McpResult.Failure(
"GitHub token not configured. Add DANGEROUS_GITHUB_TOKEN to local.properties"
)
}
}
}

/**
* Gets the GitHub token from BuildConfig.
* Returns null if not configured.
*/
private fun getGithubToken(): String? {
val token = BuildConfig.DANGEROUS_GITHUB_TOKEN
return if (token.isNotBlank()) token else null
}

/**
* Gets the API key from encrypted SharedPreferences.
* Falls back to BuildConfig default if not found.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import llc.lookatwhataicando.codeoba.core.CodeobaApp
import llc.lookatwhataicando.codeoba.core.data.CompanionProxyStub
import llc.lookatwhataicando.codeoba.core.data.McpClientImpl
import llc.lookatwhataicando.codeoba.core.data.realtime.RealtimeClientImpl
import llc.lookatwhataicando.codeoba.core.domain.createLogger
import llc.lookatwhataicando.codeoba.core.domain.realtime.RealtimeConfig
import llc.lookatwhataicando.codeoba.core.platform.DesktopAudioCaptureService
import llc.lookatwhataicando.codeoba.core.platform.DesktopAudioRouteManager
Expand All @@ -20,12 +21,13 @@ import java.util.Properties

fun main() = application {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val logger = createLogger()

val codeobaApp = CodeobaApp(
audioCaptureService = DesktopAudioCaptureService(),
audioRouteManager = DesktopAudioRouteManager(),
realtimeClient = RealtimeClientImpl(),
mcpClient = McpClientImpl(),
mcpClient = createMcpClient(logger),
companionProxy = CompanionProxyStub(),
scope = scope
)
Expand Down Expand Up @@ -55,6 +57,59 @@ fun main() = application {
}
}

/**
* Creates MCP client with GitHub token if configured, otherwise returns a stub.
*/
private fun createMcpClient(logger: llc.lookatwhataicando.codeoba.core.domain.Logger): llc.lookatwhataicando.codeoba.core.domain.McpClient {
val githubToken = getGithubToken()
return if (githubToken != null) {
logger.i("MCP Client", "Initializing with GitHub token")
McpClientImpl(
githubToken = githubToken,
logger = logger
)
} else {
logger.w("MCP Client", "No GitHub token configured, MCP features disabled")
// Return stub implementation that doesn't connect
object : llc.lookatwhataicando.codeoba.core.domain.McpClient {
override suspend fun connect() {
// No-op for stub
}

override suspend fun handleToolCall(name: String, argsJson: String) =
llc.lookatwhataicando.codeoba.core.domain.McpResult.Failure(
"GitHub token not configured. Set GITHUB_TOKEN environment variable or add DANGEROUS_GITHUB_TOKEN to local.properties"
)
}
}
}

/**
* Gets the GitHub token from various sources in priority order:
* 1. GITHUB_TOKEN environment variable
* 2. github.token system property
* 3. DANGEROUS_GITHUB_TOKEN from local.properties
*/
private fun getGithubToken(): String? {
// Check environment variable
System.getenv("GITHUB_TOKEN")?.let { if (it.isNotBlank()) return it }

// Check system property
System.getProperty("github.token")?.let { if (it.isNotBlank()) return it }

// Check local.properties file
val localPropertiesFile = File("local.properties")
if (localPropertiesFile.exists()) {
val properties = Properties()
localPropertiesFile.inputStream().use { properties.load(it) }
properties.getProperty("DANGEROUS_GITHUB_TOKEN")?.let {
if (it.isNotBlank()) return it
}
}

return null
}

/**
* Gets the API key from various sources in priority order:
* 1. OPENAI_API_KEY environment variable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ class CodeobaApp(
}
}

// Initialize MCP client connection
scope.launch {
try {
mcpClient.connect()
addEventLogEntry(EventLogEntry.Info("MCP client initialized"))
} catch (e: Exception) {
addEventLogEntry(EventLogEntry.Info("MCP client initialization deferred: ${e.message}"))
}
}

// Note: With WebRTC JavaAudioDeviceModule, audio capture and transmission
// is handled automatically by WebRTC. No need to manually pipe audio frames.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import llc.lookatwhataicando.codeoba.core.domain.Logger

@Suppress("UnnecessaryComposedModifier")
@Stable
Expand All @@ -16,3 +17,10 @@ fun Modifier.mirror(): Modifier = composed {
this
}
}

/**
* Extension function to provide a simpler logging interface.
*/
fun Logger.log(tag: String, message: String) {
i(tag, message)
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,188 @@
package llc.lookatwhataicando.codeoba.core.data

import kotlinx.serialization.json.*
import llc.lookatwhataicando.codeoba.core.data.mcp.*
import llc.lookatwhataicando.codeoba.core.domain.Logger
import llc.lookatwhataicando.codeoba.core.domain.McpClient
import llc.lookatwhataicando.codeoba.core.domain.McpResult
import llc.lookatwhataicando.codeoba.core.log

/**
* Stub implementation of McpClient for MVP.
* Future: Implement with actual MCP protocol communication.
* Implementation of McpClient that connects to GitHub's MCP server.
* Uses JSON-RPC 2.0 over HTTP to communicate with the MCP server.
*
* @param githubToken GitHub Personal Access Token or OAuth token
* @param mcpServerUrl MCP server endpoint URL (defaults to GitHub's MCP server)
* @param logger Logger instance for debugging
*/
class McpClientImpl : McpClient {
class McpClientImpl(
private val githubToken: String,
private val mcpServerUrl: String = "https://api.githubcopilot.com/mcp/",
private val logger: Logger
) : McpClient {

private lateinit var transport: McpTransport
private var isInitialized = false
private val toolsCache = mutableMapOf<String, ToolDefinition>()

private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}

/**
* Initialize connection to the MCP server and discover available tools.
*/
override suspend fun connect() {
if (isInitialized) {
logger.d("MCP Client", "Already initialized")
return
}

try {
transport = McpTransport(mcpServerUrl, githubToken, logger)

// Step 1: Initialize connection with server
logger.i("MCP Client", "Initializing connection to $mcpServerUrl")
val initParams = buildJsonObject {
put("protocolVersion", "2024-11-05")
put("capabilities", buildJsonObject {})
put("clientInfo", buildJsonObject {
put("name", "Codeoba")
put("version", "1.0.0")
})
}

val initResponse = transport.sendRequest("initialize", initParams)

if (initResponse.error != null) {
throw McpClientException("Initialization failed: ${initResponse.error.message}")
}

logger.i("MCP Client", "Connection initialized successfully")

// Step 2: Discover available tools
discoverTools()

isInitialized = true
} catch (e: Exception) {
logger.e("MCP Client", "Failed to connect: ${e.message}", e)
throw McpClientException("Failed to connect to MCP server: ${e.message}", e)
}
}

/**
* Discover and cache available tools from the MCP server.
*/
private suspend fun discoverTools() {
logger.d("MCP Client", "Discovering available tools")

val response = transport.sendRequest("tools/list")

if (response.error != null) {
throw McpClientException("Failed to list tools: ${response.error.message}")
}

val result = response.result ?: throw McpClientException("No result in tools/list response")

try {
val toolsListResult = json.decodeFromJsonElement<ToolsListResult>(result)
toolsCache.clear()
toolsListResult.tools.forEach { tool ->
toolsCache[tool.name] = tool
logger.d("MCP Client", "Discovered tool: ${tool.name}")
}
logger.i("MCP Client", "Discovered ${toolsCache.size} tools")
} catch (e: Exception) {
throw McpClientException("Failed to parse tools list: ${e.message}", e)
}
}

/**
* Execute a tool call via the MCP server.
*/
override suspend fun handleToolCall(name: String, argsJson: String): McpResult {
// TODO: Implement actual MCP tool calls
return when (name) {
"open_repo" -> McpResult.Success("Repository opened: $argsJson")
"create_or_edit_file" -> McpResult.Success("File created/edited: $argsJson")
"create_commit" -> McpResult.Success("Commit created: $argsJson")
"create_branch" -> McpResult.Success("Branch created: $argsJson")
"create_pull_request" -> McpResult.Success("PR created: $argsJson")
else -> McpResult.Failure("Unknown tool: $name")
// Ensure we're initialized
if (!isInitialized) {
try {
connect()
} catch (e: Exception) {
return McpResult.Failure("MCP client not initialized: ${e.message}")
}
}

// Check if tool exists
if (!toolsCache.containsKey(name)) {
logger.w("MCP Client", "Unknown tool: $name")
return McpResult.Failure("Unknown tool: $name. Available tools: ${toolsCache.keys.joinToString()}")
}

logger.d("MCP Client", "Executing tool: $name with args: $argsJson")

try {
// Parse arguments JSON
val arguments = json.parseToJsonElement(argsJson) as? JsonObject
?: throw McpClientException(
"Expected JSON object for tool arguments, got: ${argsJson.take(100)}${if (argsJson.length > 100) "..." else ""}"
)

// Build tool call params
val params = buildJsonObject {
put("name", name)
put("arguments", arguments)
}

// Execute tool call
val response = transport.sendRequest("tools/call", params)

if (response.error != null) {
logger.w("MCP Client", "Tool call failed: ${response.error.message}")
return McpResult.Failure("Tool execution failed: ${response.error.message}")
}

val result = response.result ?: throw McpClientException("No result in tool call response")
val toolCallResult = json.decodeFromJsonElement<ToolCallResult>(result)

// Check if tool reported an error
if (toolCallResult.isError == true) {
val errorMessage = toolCallResult.content.firstOrNull()?.text
?: "Tool reported an error"
logger.w("MCP Client", "Tool returned error: $errorMessage")
return McpResult.Failure(errorMessage)
}

// Extract success result
val summary = toolCallResult.content
.mapNotNull { it.text }
.joinToString("\n")
.ifBlank { "Tool executed successfully" }

logger.i("MCP Client", "Tool executed successfully: $summary")
return McpResult.Success(summary)

} catch (e: McpTransportException) {
logger.e("MCP Client", "Transport error: ${e.message}", e)
return McpResult.Failure("Network error: ${e.message}")
} catch (e: Exception) {
logger.e("MCP Client", "Unexpected error: ${e.message}", e)
return McpResult.Failure("Failed to execute tool: ${e.message}")
}
}

/**
* Close the MCP connection.
*/
fun disconnect() {
if (::transport.isInitialized) {
transport.close()
}
isInitialized = false
toolsCache.clear()
logger.d("MCP Client", "Disconnected")
}
}

/**
* Exception thrown when MCP client operations fail.
*/
class McpClientException(message: String, cause: Throwable? = null) : Exception(message, cause)
Loading
Loading