From e3ef867a51e59344ba8c16aafca2f1f1698aae2c Mon Sep 17 00:00:00 2001 From: mariano Date: Thu, 4 Sep 2025 18:23:26 -0500 Subject: [PATCH 1/4] fea: async tools --- .../kotlin/mcp/code/analysis/server/Mcp.kt | 229 +++++++++++++++--- 1 file changed, 193 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt index 585f679..fc73ad2 100644 --- a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt +++ b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt @@ -18,6 +18,7 @@ import io.modelcontextprotocol.kotlin.sdk.server.SseServerTransport import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport import io.modelcontextprotocol.kotlin.sdk.server.mcp import java.util.Locale.getDefault +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* import kotlinx.io.asSink import kotlinx.io.asSource @@ -48,8 +49,10 @@ class Mcp( ) ), ) { - private val analysisTimeoutMs: Long = 3_600_000L // 60 minutes - private val analysisTimeoutMinutes: Long = analysisTimeoutMs / 60_000L + + private val asyncOperations = ConcurrentHashMap() + private val operationResults = ConcurrentHashMap() + private val operationProgress = ConcurrentHashMap() /** Starts an MCP server using standard input/output (stdio) for communication. */ fun runUsingStdio() { @@ -81,6 +84,7 @@ class Mcp( embeddedServer(CIO, host = "0.0.0.0", port = port) { install(SSE) + install(CORS) { anyHost() allowCredentials = true @@ -90,6 +94,8 @@ class Mcp( allowMethod(HttpMethod.Get) allowHeader(HttpHeaders.ContentType) allowHeader(HttpHeaders.Accept) + allowHeader(HttpHeaders.Authorization) + allowHeader("X-Requested-With") allowHeader("Cache-Control") } @@ -104,13 +110,15 @@ class Mcp( servers[transport.sessionId] = server logger.info("Created server for session: ${transport.sessionId}") - val heartbeatJob = launch { + send(ServerSentEvent("connected", event = "connection")) + + val keepAliveJob = launch { while (isActive) { try { - send(ServerSentEvent("heartbeat", event = "ping")) - delay(25_000) + send(ServerSentEvent("ping", event = "keepalive")) + delay(10_000) } catch (e: Exception) { - logger.debug("Heartbeat failed: ${e.message}") + logger.debug("Keep-alive failed for session ${transport.sessionId}: ${e.message}") break } } @@ -119,7 +127,12 @@ class Mcp( server.onClose { logger.info("Server closed for session: ${transport.sessionId}") servers.remove(transport.sessionId) - heartbeatJob.cancel() + keepAliveJob.cancel() + asyncOperations.keys.forEach { operationKey -> + asyncOperations[operationKey]?.cancel() + asyncOperations.remove(operationKey) + operationProgress.remove(operationKey) + } } try { @@ -131,7 +144,7 @@ class Mcp( logger.error("Connection error for session ${transport.sessionId}: ${e.message}", e) throw e } finally { - heartbeatJob.cancel() + keepAliveJob.cancel() servers.remove(transport.sessionId) logger.info("SSE connection closed for session: ${transport.sessionId}") } @@ -139,7 +152,7 @@ class Mcp( post("/message") { try { - withTimeout(analysisTimeoutMs) { + withTimeout(15_000) { val sessionId = call.request.queryParameters["sessionId"] ?: return@withTimeout call.respond(HttpStatusCode.BadRequest, "Missing sessionId parameter") @@ -160,13 +173,13 @@ class Mcp( return@withTimeout } - logger.debug("Handling message for session: $sessionId") + logger.debug("Processing message for session: $sessionId") transport.handlePostMessage(call) call.respond(HttpStatusCode.OK) } - } catch (e: TimeoutCancellationException) { - logger.error("Message handling timed out") - call.respond(HttpStatusCode.RequestTimeout, "Request timed out") + } catch (_: TimeoutCancellationException) { + logger.error("Message handling timed out after 15 seconds") + call.respond(HttpStatusCode.RequestTimeout, "Request processing timed out") } catch (e: Exception) { logger.error("Error handling message: ${e.message}", e) call.respond(HttpStatusCode.InternalServerError, "Error: ${e.message}") @@ -209,7 +222,7 @@ class Mcp( logger.info("Configuring MCP server with implementation: ${implementation.name} v${implementation.version}") val server = SdkServer(implementation, serverOptions) - server.addTool( + server. addTool( name = "analyze-repository", description = "Analyzes GitHub repositories to provide code insights and structure summary", inputSchema = @@ -242,33 +255,177 @@ class Mcp( arguments["repoUrl"]?.jsonPrimitive?.content ?: throw IllegalArgumentException("Missing repoUrl parameter") val branch = arguments["branch"]?.jsonPrimitive?.content ?: "main" - val startTime = System.currentTimeMillis() - logger.info("Starting repository analysis for: $repoUrl") - val result = withTimeout(analysisTimeoutMs) { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } - val duration = System.currentTimeMillis() - startTime - logger.info("Analysis completed in ${duration}ms") - - CallToolResult(content = listOf(TextContent(result))) - } catch (e: TimeoutCancellationException) { - CallToolResult( - content = - listOf( - TextContent( - buildString { - append("Repository analysis timed out after $analysisTimeoutMinutes minutes. ") - append("Large repositories may take longer to analyze. ") - append("Try with a smaller repository or specific branch.") - } - ) - ), - isError = true, - ) + val operationKey = "$repoUrl:$branch" + operationResults[operationKey]?.let { result -> + return@addTool CallToolResult(content = listOf(TextContent("Cached result: $result"))) + } + + try { + val startTime = System.currentTimeMillis() + logger.info("Starting repository analysis for: $repoUrl") + + val result = withTimeout(20_000) { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } + + val duration = System.currentTimeMillis() - startTime + logger.info("Analysis completed in ${duration}ms") + + operationResults[operationKey] = result + CallToolResult(content = listOf(TextContent(result))) + } catch (_: TimeoutCancellationException) { + val asyncJob = + CoroutineScope(Dispatchers.IO).launch { + try { + logger.info("Starting background analysis for: $repoUrl") + operationProgress[operationKey] = "Starting analysis..." + operationProgress[operationKey] = "Processing files and dependencies..." + val result = repositoryAnalysisService.analyzeRepository(repoUrl, branch) + + operationResults[operationKey] = result + operationProgress[operationKey] = "Analysis completed successfully" + + logger.info("Background analysis completed for: $repoUrl") + } catch (e: Exception) { + logger.error("Background analysis failed for $repoUrl: ${e.message}", e) + operationProgress[operationKey] = "Analysis failed: ${e.message}" + } finally { + asyncOperations.remove(operationKey) + } + } + + asyncOperations[operationKey] = asyncJob + + CallToolResult( + content = + listOf( + TextContent( + buildString { + append("Repository analysis started in the background for: $repoUrl (branch: $branch).") + append("This may take several minutes for large repositories. ") + append("Use 'check-analysis-status' tool to monitor progress.") + } + ) + ), + isError = false, + ) + } } catch (e: Exception) { logger.error("Analysis failed: ${e.message}", e) CallToolResult(content = listOf(TextContent("Error analyzing repository: ${e.message}")), isError = true) } } + server.addTool( + name = "check-analysis-status", + description = "Check the status of a repository analysis operation", + inputSchema = + Tool.Input( + properties = + JsonObject( + mapOf( + "repoUrl" to + JsonObject( + mapOf("type" to JsonPrimitive("string"), "description" to JsonPrimitive("GitHub repository URL")) + ), + "branch" to + JsonObject( + mapOf( + "type" to JsonPrimitive("string"), + "description" to JsonPrimitive("Branch to check (default: main)"), + ) + ), + ) + ), + required = listOf("repoUrl"), + ), + ) { request -> + try { + val repoUrl = + request.arguments["repoUrl"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Missing repoUrl parameter") + val branch = request.arguments["branch"]?.jsonPrimitive?.content ?: "main" + val operationKey = "$repoUrl:$branch" + + when { + operationResults.containsKey(operationKey) -> { + val result = operationResults[operationKey]!! + CallToolResult(content = listOf(TextContent("Analysis completed successfully:\n\n$result"))) + } + asyncOperations.containsKey(operationKey) -> { + val progress = operationProgress[operationKey] ?: "Analysis in progress..." + val job = asyncOperations[operationKey]!! + val status = + when { + job.isCompleted -> "Completed" + job.isCancelled -> "Cancelled" + else -> "Running" + } + CallToolResult(content = listOf(TextContent("Analysis status: $status\nProgress: $progress"))) + } + operationProgress.containsKey(operationKey) -> { + val progress = operationProgress[operationKey]!! + if (progress.startsWith("Analysis failed:")) { + CallToolResult( + content = listOf(TextContent("Analysis failed: ${progress.substring(16)}")), + isError = true, + ) + } else { + CallToolResult(content = listOf(TextContent("Final status: $progress"))) + } + } + else -> { + CallToolResult( + content = listOf(TextContent("No analysis found for this repository. Run 'analyze-repository' first.")) + ) + } + } + } catch (e: Exception) { + CallToolResult(content = listOf(TextContent("Error checking status: ${e.message}")), isError = true) + } + } + + server.addTool( + name = "cancel-analysis", + description = "Cancel a running repository analysis operation", + inputSchema = + Tool.Input( + properties = + JsonObject( + mapOf( + "repoUrl" to + JsonObject( + mapOf("type" to JsonPrimitive("string"), "description" to JsonPrimitive("GitHub repository URL")) + ), + "branch" to + JsonObject( + mapOf( + "type" to JsonPrimitive("string"), + "description" to JsonPrimitive("Branch to cancel (default: main)"), + ) + ), + ) + ), + required = listOf("repoUrl"), + ), + ) { request -> + try { + val repoUrl = + request.arguments["repoUrl"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Missing repoUrl parameter") + val branch = request.arguments["branch"]?.jsonPrimitive?.content ?: "main" + val operationKey = "$repoUrl:$branch" + + asyncOperations[operationKey]?.let { job -> + job.cancel() + asyncOperations.remove(operationKey) + operationProgress[operationKey] = "Analysis cancelled by user" + + CallToolResult(content = listOf(TextContent("Analysis cancelled for repository: $repoUrl (branch: $branch)"))) + } ?: CallToolResult(content = listOf(TextContent("No running analysis found for this repository."))) + } catch (e: Exception) { + CallToolResult(content = listOf(TextContent("Error cancelling analysis: ${e.message}")), isError = true) + } + } + server.addPrompt( name = "analyze-codebase", description = "Generate a comprehensive analysis prompt for a codebase", @@ -396,7 +553,7 @@ class Mcp( ) } - logger.info("MCP server configured successfully with 1 tool, 2 prompts, and 2 resources") + logger.info("MCP server configured successfully with 3 tools, 2 prompts, and 2 resources") return server } } From 98488bc2995e5ab41259426a71291b8c99928e26 Mon Sep 17 00:00:00 2001 From: mariano Date: Thu, 4 Sep 2025 18:48:12 -0500 Subject: [PATCH 2/4] fea: async tools --- .../kotlin/mcp/code/analysis/server/Mcp.kt | 73 +++++++++++++++---- .../mcp/code/analysis/server/McpTest.kt | 33 +++++---- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt index fc73ad2..e890cba 100644 --- a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt +++ b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt @@ -214,15 +214,30 @@ class Mcp( } /** - * Configures the MCP server with tools and their respective functionalities. + * Configures the MCP server with tools, prompts, and resources for GitHub repository analysis. * - * @return The configured MCP server instance. + * Sets up three main tools: + * - **analyze-repository**: Analyzes GitHub repositories to provide comprehensive code insights and structure + * summary. Supports any public GitHub repository URL, branch-specific analysis (defaults to 'main'), automatic + * caching to prevent duplicate analysis, synchronous analysis for quick responses (up to 20s timeout), and + * background processing for large repositories with progress tracking. + * - **check-analysis-status**: Monitors the progress and completion status of repository analysis operations. + * Provides real-time progress tracking for background analyses, status reporting (Running, Completed, Cancelled, + * Failed), retrieval of completed analysis results, and error reporting for failed operations. + * - **cancel-analysis**: Cancels running repository analysis operations with optional cache management. Offers + * immediate cancellation of background analysis jobs, optional cache clearing to remove stored results, detailed + * feedback on what actions were performed, and backward compatibility with existing usage patterns. + * + * Additionally, configures prompts for codebase analysis and code review templates, plus resources for accessing + * analysis results and repository metrics. + * + * @return The configured MCP server instance with all tools, prompts, and resources registered. */ fun configureServer(): SdkServer { logger.info("Configuring MCP server with implementation: ${implementation.name} v${implementation.version}") val server = SdkServer(implementation, serverOptions) - server. addTool( + server.addTool( name = "analyze-repository", description = "Analyzes GitHub repositories to provide code insights and structure summary", inputSchema = @@ -298,11 +313,11 @@ class Mcp( content = listOf( TextContent( - buildString { - append("Repository analysis started in the background for: $repoUrl (branch: $branch).") - append("This may take several minutes for large repositories. ") - append("Use 'check-analysis-status' tool to monitor progress.") - } + buildString { + append("Repository analysis started in the background for: $repoUrl (branch: $branch).") + append("This may take several minutes for large repositories. ") + append("Use 'check-analysis-status' tool to monitor progress.") + } ) ), isError = false, @@ -385,7 +400,7 @@ class Mcp( server.addTool( name = "cancel-analysis", - description = "Cancel a running repository analysis operation", + description = "Cancel a running repository analysis operation with optional cache clearing", inputSchema = Tool.Input( properties = @@ -402,6 +417,13 @@ class Mcp( "description" to JsonPrimitive("Branch to cancel (default: main)"), ) ), + "clearCache" to + JsonObject( + mapOf( + "type" to JsonPrimitive("boolean"), + "description" to JsonPrimitive("Whether to clear cached results (default: false)"), + ) + ), ) ), required = listOf("repoUrl"), @@ -412,15 +434,36 @@ class Mcp( request.arguments["repoUrl"]?.jsonPrimitive?.content ?: throw IllegalArgumentException("Missing repoUrl parameter") val branch = request.arguments["branch"]?.jsonPrimitive?.content ?: "main" + val clearCache = request.arguments["clearCache"]?.jsonPrimitive?.content?.toBoolean() ?: false val operationKey = "$repoUrl:$branch" - asyncOperations[operationKey]?.let { job -> - job.cancel() - asyncOperations.remove(operationKey) - operationProgress[operationKey] = "Analysis cancelled by user" + val hadRunningOperation = asyncOperations[operationKey] != null + val hadCachedResults = operationResults.containsKey(operationKey) + + asyncOperations[operationKey]?.cancel() + asyncOperations.remove(operationKey) + + if (clearCache) { + operationResults.remove(operationKey) + } + + operationProgress[operationKey] = "Analysis cancelled by user" + + val repoInfo = "$repoUrl (branch: $branch)" + val message = + when { + hadRunningOperation && clearCache -> { + val cacheMsg = if (hadCachedResults) " and cached results cleared" else " and cache cleared" + "Analysis cancelled$cacheMsg for repository: $repoInfo" + } + hadRunningOperation -> "Analysis cancelled for repository: $repoInfo. Cached results preserved." + clearCache && hadCachedResults -> + "No running analysis found, but cleared cached results for repository: $repoInfo" + clearCache -> "No running analysis or cached results found for repository: $repoInfo" + else -> "No running analysis found for this repository." + } - CallToolResult(content = listOf(TextContent("Analysis cancelled for repository: $repoUrl (branch: $branch)"))) - } ?: CallToolResult(content = listOf(TextContent("No running analysis found for this repository."))) + CallToolResult(content = listOf(TextContent(message))) } catch (e: Exception) { CallToolResult(content = listOf(TextContent("Error cancelling analysis: ${e.message}")), isError = true) } diff --git a/src/test/kotlin/mcp/code/analysis/server/McpTest.kt b/src/test/kotlin/mcp/code/analysis/server/McpTest.kt index 4eb54ba..7d7ce38 100644 --- a/src/test/kotlin/mcp/code/analysis/server/McpTest.kt +++ b/src/test/kotlin/mcp/code/analysis/server/McpTest.kt @@ -18,8 +18,7 @@ class McpTest { private lateinit var repositoryAnalysisService: RepositoryAnalysisService private lateinit var serverUnderTest: Mcp - private val toolHandlerSlot = slot CallToolResult>() - private val resourceHandlerSlot = slot String>() + private val toolHandlers = mutableMapOf CallToolResult>() @BeforeEach fun setUp() { @@ -30,8 +29,18 @@ class McpTest { every { anyConstructed() - .addTool(name = any(), description = any(), inputSchema = any(), handler = capture(toolHandlerSlot)) - } returns Unit + .addTool( + name = capture(slot()), + description = any(), + inputSchema = any(), + handler = capture(slot CallToolResult>()), + ) + } answers + { + val toolName = firstArg() + val handler = lastArg CallToolResult>() + toolHandlers[toolName] = handler + } every { anyConstructed() @@ -47,6 +56,7 @@ class McpTest { @AfterEach fun tearDown() { unmockkConstructor(SdkServer::class) + toolHandlers.clear() } @Test @@ -62,12 +72,7 @@ class McpTest { verify { anyConstructed() - .addTool( - name = eq("analyze-repository"), - description = any(), - inputSchema = any(), - handler = toolHandlerSlot.captured, - ) + .addTool(name = eq("analyze-repository"), description = any(), inputSchema = any(), handler = any()) } val request = @@ -75,7 +80,7 @@ class McpTest { arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), name = "analyze-repository", ) - val result = toolHandlerSlot.captured.invoke(request) + val result = toolHandlers["analyze-repository"]!!.invoke(request) // Assert assertFalse(result.isError == true, "Result should not be an error on success") @@ -103,7 +108,7 @@ class McpTest { arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), name = "analyze-repository", ) - val result = toolHandlerSlot.captured.invoke(request) + val result = toolHandlers["analyze-repository"]!!.invoke(request) // Assert assertTrue(result.isError == true, "Result should be an error") @@ -124,7 +129,7 @@ class McpTest { val request = CallToolRequest(arguments = JsonObject(mapOf("branch" to JsonPrimitive("main"))), name = "analyze-repository") - val result = toolHandlerSlot.captured.invoke(request) + val result = toolHandlers["analyze-repository"]!!.invoke(request) // Assert assertTrue(result.isError == true, "Result should be an error for missing repoUrl") @@ -151,7 +156,7 @@ class McpTest { val request = CallToolRequest(arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl))), name = "analyze-repository") - val result = toolHandlerSlot.captured.invoke(request) + val result = toolHandlers["analyze-repository"]!!.invoke(request) // Assert assertFalse(result.isError == true, "Result should not be an error when using default branch") From c0b4a909361c88aa053da3b07b1479d3bc4806d1 Mon Sep 17 00:00:00 2001 From: mariano Date: Thu, 4 Sep 2025 19:16:44 -0500 Subject: [PATCH 3/4] feat: async tools --- build.gradle.kts | 11 +++--- .../kotlin/mcp/code/analysis/server/Mcp.kt | 34 ++++++++++++++++--- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 88efba5..22434cd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,6 +39,11 @@ dependencies { // Kotlin standard library implementation(kotlin("stdlib")) + // Kotlin + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.8.0") + // Ktor server implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion") @@ -56,12 +61,6 @@ dependencies { // Logging implementation("ch.qos.logback:logback-classic:1.5.18") - // Coroutines - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - - // Serialization - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") - // JGit for repository interaction implementation("org.eclipse.jgit:org.eclipse.jgit:7.2.1.202505142326-r") diff --git a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt index e890cba..4a0c4ee 100644 --- a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt +++ b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt @@ -271,10 +271,33 @@ class Mcp( val branch = arguments["branch"]?.jsonPrimitive?.content ?: "main" val operationKey = "$repoUrl:$branch" + operationResults[operationKey]?.let { result -> return@addTool CallToolResult(content = listOf(TextContent("Cached result: $result"))) } + asyncOperations[operationKey]?.let { job -> + if (job.isActive) { + val progress = operationProgress[operationKey] ?: "Analysis in progress..." + return@addTool CallToolResult( + content = + listOf( + TextContent( + """ + Analysis already in progress for: $repoUrl (branch: $branch). + Current progress: $progress + Use 'check-analysis-status' to monitor progress. + """ + .trimIndent() + ) + ) + ) + } else { + // Clean up completed/cancelled job + asyncOperations.remove(operationKey) + } + } + try { val startTime = System.currentTimeMillis() logger.info("Starting repository analysis for: $repoUrl") @@ -313,11 +336,12 @@ class Mcp( content = listOf( TextContent( - buildString { - append("Repository analysis started in the background for: $repoUrl (branch: $branch).") - append("This may take several minutes for large repositories. ") - append("Use 'check-analysis-status' tool to monitor progress.") - } + """ + Repository analysis started in the background for: $repoUrl (branch: $branch). + This may take several minutes for large repositories. + Use 'check-analysis-status' tool to monitor progress. + """ + .trimIndent() ) ), isError = false, From 310cc3c8a1e486b07a121e89100e77377c554a69 Mon Sep 17 00:00:00 2001 From: mariano Date: Thu, 4 Sep 2025 19:34:13 -0500 Subject: [PATCH 4/4] feat: async tools --- .../kotlin/mcp/code/analysis/server/Mcp.kt | 1 - .../mcp/code/analysis/server/McpTest.kt | 228 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt index 4a0c4ee..9ec1e9d 100644 --- a/src/main/kotlin/mcp/code/analysis/server/Mcp.kt +++ b/src/main/kotlin/mcp/code/analysis/server/Mcp.kt @@ -293,7 +293,6 @@ class Mcp( ) ) } else { - // Clean up completed/cancelled job asyncOperations.remove(operationKey) } } diff --git a/src/test/kotlin/mcp/code/analysis/server/McpTest.kt b/src/test/kotlin/mcp/code/analysis/server/McpTest.kt index 7d7ce38..7d89a52 100644 --- a/src/test/kotlin/mcp/code/analysis/server/McpTest.kt +++ b/src/test/kotlin/mcp/code/analysis/server/McpTest.kt @@ -5,6 +5,7 @@ import io.modelcontextprotocol.kotlin.sdk.CallToolRequest import io.modelcontextprotocol.kotlin.sdk.CallToolResult import io.modelcontextprotocol.kotlin.sdk.TextContent import io.modelcontextprotocol.kotlin.sdk.server.Server as SdkServer +import kotlin.text.get import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -166,4 +167,231 @@ class McpTest { assertEquals(expectedSummary, textContent?.text) coVerify { repositoryAnalysisService.analyzeRepository(repoUrl, defaultBranch) } } + + @Test + fun `check-analysis-status tool handler returns completed analysis result`() = runBlocking { + // Arrange + val repoUrl = "https://github.com/test/repo" + val branch = "main" + val analysisResult = "Analysis completed successfully" + coEvery { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } returns analysisResult + + // Act + serverUnderTest.configureServer() + + // First analyze repository to cache result + val analyzeRequest = + CallToolRequest( + arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), + name = "analyze-repository", + ) + toolHandlers["analyze-repository"]!!.invoke(analyzeRequest) + + // Then check status + val statusRequest = + CallToolRequest( + arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), + name = "check-analysis-status", + ) + val result = toolHandlers["check-analysis-status"]!!.invoke(statusRequest) + + // Assert + assertFalse(result.isError == true, "Result should not be an error") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("Analysis completed successfully") == true, + "Should contain completion message. Actual: ${textContent?.text}", + ) + } + + @Test + fun `check-analysis-status tool handler returns no analysis found`() = runBlocking { + // Arrange + val repoUrl = "https://github.com/test/repo" + val branch = "main" + + // Act + serverUnderTest.configureServer() + + val request = + CallToolRequest( + arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), + name = "check-analysis-status", + ) + val result = toolHandlers["check-analysis-status"]!!.invoke(request) + + // Assert + assertFalse(result.isError == true, "Result should not be an error") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("No analysis found for this repository") == true, + "Should contain no analysis message. Actual: ${textContent?.text}", + ) + } + + @Test + fun `check-analysis-status tool handler uses default branch if not provided`() = runBlocking { + // Arrange + val repoUrl = "https://github.com/test/repo" + val defaultBranch = "main" + + // Act + serverUnderTest.configureServer() + + val request = + CallToolRequest( + arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl))), + name = "check-analysis-status", + ) + val result = toolHandlers["check-analysis-status"]!!.invoke(request) + + // Assert + assertFalse(result.isError == true, "Result should not be an error") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("No analysis found for this repository") == true, + "Should handle default branch correctly. Actual: ${textContent?.text}", + ) + } + + @Test + fun `check-analysis-status tool handler processes missing repoUrl argument`() = runBlocking { + // Act + serverUnderTest.configureServer() + + val request = + CallToolRequest(arguments = JsonObject(mapOf("branch" to JsonPrimitive("main"))), name = "check-analysis-status") + val result = toolHandlers["check-analysis-status"]!!.invoke(request) + + // Assert + assertTrue(result.isError == true, "Result should be an error for missing repoUrl") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("Error checking status: Missing repoUrl parameter") == true, + "Error message for missing repoUrl mismatch. Actual: ${textContent?.text}", + ) + } + + @Test + fun `cancel-analysis tool handler cancels running operation`() = runBlocking { + // Arrange + val repoUrl = "https://github.com/test/repo" + val branch = "main" + + // Act + serverUnderTest.configureServer() + + val request = + CallToolRequest( + arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), + name = "cancel-analysis", + ) + val result = toolHandlers["cancel-analysis"]!!.invoke(request) + + // Assert + assertFalse(result.isError == true, "Result should not be an error") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("No running analysis found") == true, + "Should indicate no running analysis. Actual: ${textContent?.text}", + ) + } + + @Test + fun `cancel-analysis tool handler with clearCache clears cached results`() = runBlocking { + // Arrange + val repoUrl = "https://github.com/test/repo" + val branch = "main" + val analysisResult = "Analysis completed" + coEvery { repositoryAnalysisService.analyzeRepository(repoUrl, branch) } returns analysisResult + + // Act + serverUnderTest.configureServer() + + // First analyze repository to cache result + val analyzeRequest = + CallToolRequest( + arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl), "branch" to JsonPrimitive(branch))), + name = "analyze-repository", + ) + toolHandlers["analyze-repository"]!!.invoke(analyzeRequest) + + // Then cancel with clearCache=true + val cancelRequest = + CallToolRequest( + arguments = + JsonObject( + mapOf( + "repoUrl" to JsonPrimitive(repoUrl), + "branch" to JsonPrimitive(branch), + "clearCache" to JsonPrimitive("true"), + ) + ), + name = "cancel-analysis", + ) + val result = toolHandlers["cancel-analysis"]!!.invoke(cancelRequest) + + // Assert + assertFalse(result.isError == true, "Result should not be an error") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("cleared cached results") == true, + "Should indicate cache was cleared. Actual: ${textContent?.text}", + ) + } + + @Test + fun `cancel-analysis tool handler uses default branch if not provided`() = runBlocking { + // Arrange + val repoUrl = "https://github.com/test/repo" + + // Act + serverUnderTest.configureServer() + + val request = + CallToolRequest(arguments = JsonObject(mapOf("repoUrl" to JsonPrimitive(repoUrl))), name = "cancel-analysis") + val result = toolHandlers["cancel-analysis"]!!.invoke(request) + + // Assert + assertFalse(result.isError == true, "Result should not be an error") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("No running analysis found") == true, + "Should handle default branch correctly. Actual: ${textContent?.text}", + ) + } + + @Test + fun `cancel-analysis tool handler processes missing repoUrl argument`() = runBlocking { + // Act + serverUnderTest.configureServer() + + val request = + CallToolRequest(arguments = JsonObject(mapOf("branch" to JsonPrimitive("main"))), name = "cancel-analysis") + val result = toolHandlers["cancel-analysis"]!!.invoke(request) + + // Assert + assertTrue(result.isError == true, "Result should be an error for missing repoUrl") + assertEquals(1, result.content.size) + val textContent = result.content.first() as? TextContent + assertNotNull(textContent, "Content should be TextContent") + assertTrue( + textContent?.text?.contains("Error cancelling analysis: Missing repoUrl parameter") == true, + "Error message for missing repoUrl mismatch. Actual: ${textContent?.text}", + ) + } }