diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..236367415f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +# AGENTS.md + +This file provides guidance to AI coding agents (Claude Code, Cursor, Codex, Gemini CLI, etc.) working in this repository. + +## Project Overview + +**CPG** is a code property graph library and analysis platform. + +Key modules: +- **cpg-core** – Core library: AST nodes, graph structures, passes, type system +- **cpg-analysis** – Higher-level analyses built on top of cpg-core (dataflow, control flow, call graphs, etc.) +- **cpg-language-\*** – Language frontends, one module per language (e.g., `cpg-language-go`, `cpg-language-python`) +- **cpg-concepts** – Concept and operation definitions +- **cpg-mcp** – MCP (Model Context Protocol) server exposing CPG analysis tools to LLMs +- **codyze-console** – Web-based analysis UI: Kotlin backend + Svelte 5 frontend + +## Technology Stack + +### Backend +- **Language**: Kotlin (requires Java 21) +- **Build**: Gradle with Kotlin DSL +- **Testing**: JUnit 5, kotlin.test +- **Formatting**: Google Java Style via spotless plugin + +### Frontend (`codyze-console/src/main/webapp`) +- **Framework**: Svelte 5 with SvelteKit +- **Styling**: Tailwind CSS +- **Package Manager**: pnpm (not npm) + +## Development Commands + +### Build + +```bash +# Full build (format + test + publish locally) +./gradlew clean spotlessApply build publishToMavenLocal + +# Quick build +./gradlew build + +# Build a single module +./gradlew :cpg-core:build +./gradlew :codyze-console:build +``` + +### Test & Quality + +```bash +./gradlew test # Unit tests +./gradlew integrationTest # Integration tests +./gradlew performanceTest # Performance tests +./gradlew spotlessApply # Auto-format +./gradlew spotlessCheck # Check formatting only +``` + +### Frontend (`codyze-console`) + +```bash +cd codyze-console/src/main/webapp +pnpm install # Install dependencies +pnpm run dev # Dev server +pnpm run build # Production build +pnpm run check # Type check +pnpm run lint # Lint +pnpm run format # Format +``` + +### Backend (`codyze-console`) + +```bash +./gradlew :codyze-console:compileKotlin --console=plain +``` + +### MCP Server (`cpg-mcp`) + +```bash +./gradlew :cpg-mcp:installDist # Build & install +./gradlew :cpg-mcp:run # Run (stdio) +./gradlew :cpg-mcp:run --args="--http 8080" # Run with streamable HTTP on port 8080 +``` + +## Code Conventions + +- Follow **Google Java Style** (enforced by spotless – run `./gradlew spotlessApply` before committing) +- Kotlin idioms preferred over Java-style patterns +- Use `kotlin.test` assertions in tests, not JUnit assertions directly +- Frontend: use **Svelte 5 runes** exclusively (no legacy `$:` reactive syntax) \ No newline at end of file diff --git a/codyze-console/build.gradle.kts b/codyze-console/build.gradle.kts index f3cbe365611..cf67f8b4df3 100644 --- a/codyze-console/build.gradle.kts +++ b/codyze-console/build.gradle.kts @@ -14,13 +14,36 @@ mavenPublishing { } } +val mcpEnabled = findProject(":cpg-mcp") != null + dependencies { // CPG modules implementation(projects.cpgConcepts) + implementation(projects.cpgSerialization) + + // MCP dependencies + if (mcpEnabled) { + implementation(project(":cpg-mcp")) + // MCP SDK + implementation(libs.mcp) + // MCP Client SDK - for custom MCP client implementation + implementation(libs.mcp.client) + } else { + // MCP SDK only available at compile time so the files in `/ai` compile, + compileOnly(libs.mcp) + compileOnly(libs.mcp.client) + } // Ktor server dependencies implementation(libs.bundles.ktor) + // Ktor client dependencies + implementation(libs.bundles.ktor.client) + + // Koog AI agent framework + implementation(libs.koog.agents) + implementation(libs.koog.agents.mcp) + // Serialization implementation(libs.kotlinx.serialization.json) implementation(libs.jacksonyml) diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ConsoleService.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ConsoleService.kt index 943ffb0c578..9a73324ec5c 100644 --- a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ConsoleService.kt +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ConsoleService.kt @@ -30,6 +30,7 @@ import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.dataformat.yaml.YAMLFactory import de.fraunhofer.aisec.codyze.AnalysisProject import de.fraunhofer.aisec.codyze.AnalysisResult +import de.fraunhofer.aisec.codyze.console.ai.McpServerHelper import de.fraunhofer.aisec.cpg.TranslationConfiguration import de.fraunhofer.aisec.cpg.graph.concepts.Concept import de.fraunhofer.aisec.cpg.graph.concepts.conceptBuildHelper @@ -40,6 +41,8 @@ import de.fraunhofer.aisec.cpg.passes.concepts.LoadPersistedConcepts.PersistedCo import de.fraunhofer.aisec.cpg.passes.concepts.LoadPersistedConcepts.PersistedConcepts import de.fraunhofer.aisec.cpg.passes.concepts.config.python.PythonStdLibConfigurationPass import de.fraunhofer.aisec.cpg.query.QueryTree +import de.fraunhofer.aisec.cpg.serialization.NodeJSON +import de.fraunhofer.aisec.cpg.serialization.toJSON import java.io.File import java.nio.file.Path import kotlin.uuid.Uuid @@ -125,6 +128,9 @@ class ConsoleService { val result = project.analyze() + // Update the global analysis result in the MCP server + McpServerHelper.setGlobalAnalysisResult(result.translationResult) + // Populate QueryTree cache for lazy loading populateQueryTreeCache(result.requirementsResults) diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Main.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Main.kt index 81e45277bc0..a47afb3a6df 100644 --- a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Main.kt +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Main.kt @@ -25,7 +25,9 @@ */ package de.fraunhofer.aisec.codyze.console -import io.ktor.http.HttpHeaders +import de.fraunhofer.aisec.codyze.console.ai.ChatService +import de.fraunhofer.aisec.codyze.console.ai.McpServerHelper +import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.engine.* @@ -33,6 +35,7 @@ import io.ktor.server.netty.* import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* import io.ktor.server.routing.* +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json /** @@ -41,10 +44,37 @@ import kotlinx.serialization.json.Json * the [configureWebconsole] function. */ fun ConsoleService.startConsole(host: String = "localhost", port: Int = 8080) { - embeddedServer(Netty, host = host, port = port) { configureWebconsole(this@startConsole) } + val chatService: ChatService? = + if (McpServerHelper.isEnabled) { + runBlocking { initChatService() } + } else { + println("MCP module not enabled, AI chat features will be disabled") + null + } + + println("Starting main server on port $port...") + embeddedServer(Netty, host = host, port = port) { + configureWebconsole(this@startConsole, chatService) + } .start(wait = true) } +private suspend fun ConsoleService.initChatService(): ChatService? { + val chatService = ChatService.createIfConfigExist() ?: return null + + McpServerHelper.startMcpServer(8081) + + val translationResult = getTranslationResult()?.analysisResult?.translationResult + if (translationResult != null) { + McpServerHelper.setGlobalAnalysisResult(translationResult) + } + + println("Initializing ChatService...") + chatService.connect() + println("MCP client connected!") + return chatService +} + /** * This function takes care of configuring the web console based on the [service]. It sets up the * CORS policy, content negotiation, and routing. @@ -52,7 +82,10 @@ fun ConsoleService.startConsole(host: String = "localhost", port: Int = 8080) { * Note: Currently, the CORS policy allows any host. This should be restricted to specific hosts in * a production environment and will be made available as an option later. */ -fun Application.configureWebconsole(service: ConsoleService = ConsoleService()) { +fun Application.configureWebconsole( + service: ConsoleService = ConsoleService(), + chatService: ChatService? = null, +) { install(CORS) { anyHost() allowHeader(HttpHeaders.ContentType) @@ -67,17 +100,21 @@ fun Application.configureWebconsole(service: ConsoleService = ConsoleService()) ) } - configureRouting(service) + configureRouting(service, chatService) } /** * This function sets up the routing for the web console. It defines the API routes and static * resources (for serving the single-page application frontend). */ -fun Application.configureRouting(service: ConsoleService) { +fun Application.configureRouting(service: ConsoleService, chatService: ChatService? = null) { routing { - // We'll add routes here apiRoutes(service) + // If the cpg-mcp module is enabled, chatService won't be null, so the endpoints will be + // reachable + if (chatService != null) { + chatRoutes(chatService) + } frontendRoutes() } } diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Nodes.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Nodes.kt index 59a93c41a96..1d568d575b1 100644 --- a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Nodes.kt +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Nodes.kt @@ -36,9 +36,9 @@ import de.fraunhofer.aisec.cpg.assumptions.Assumption import de.fraunhofer.aisec.cpg.graph.* import de.fraunhofer.aisec.cpg.graph.concepts.Concept import de.fraunhofer.aisec.cpg.graph.declarations.TranslationUnit -import de.fraunhofer.aisec.cpg.graph.edges.Edge import de.fraunhofer.aisec.cpg.passes.concepts.LoadPersistedConcepts.* import de.fraunhofer.aisec.cpg.query.* +import de.fraunhofer.aisec.cpg.serialization.* import io.github.detekt.sarif4k.ArtifactLocation import io.github.detekt.sarif4k.Result import java.net.URI @@ -46,14 +46,8 @@ import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.toPath import kotlin.uuid.Uuid -import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.Transient -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder /** * JSON data class for an analysis request. It contains the source directory, an optional include @@ -67,14 +61,6 @@ data class AnalyzeRequestJSON( val conceptsFile: String? = null, ) -/** JSON data class for an [Edge]. */ -@Serializable -data class EdgeJSON( - var label: String, - @Serializable(with = UuidSerializer::class) var start: Uuid, - @Serializable(with = UuidSerializer::class) var end: Uuid, -) - /** JSON data class for a SARIF [Result]. */ @Serializable data class FindingsJSON( @@ -142,25 +128,6 @@ data class ConstructorArguments( val argumentType: String? = null, // currently not used ) -/** JSON data class for a [Node]. */ -@Serializable -data class NodeJSON( - @Serializable(with = UuidSerializer::class) val id: Uuid, - val type: String, - val startLine: Int, - val startColumn: Int, - val endLine: Int, - val endColumn: Int, - val code: String, - val name: String, - val astChildren: List, - val prevDFG: List = emptyList(), - val nextDFG: List = emptyList(), - @Serializable(with = UuidSerializer::class) val translationUnitId: Uuid? = null, - val componentName: String? = null, - val fileName: String? = null, -) - /** JSON data class for a requirement category. */ @Serializable data class RequirementsCategoryJSON( @@ -309,7 +276,7 @@ fun AnalysisResult.toJSON(): AnalysisResultJSON = components = components.map { it.toJSON() }, totalNodes = nodes.size, analysisResult = this@toJSON, - sourceDir = config.sourceLocations.first().absolutePath, + sourceDir = config.sourceLocations.firstOrNull()?.absolutePath ?: "", findings = sarif.runs.flatMap { it.results?.map { it.toJSON() } ?: emptyList() }, requirementCategories = project.requirementCategoriesToJSON(this@toJSON.requirementsResults), @@ -357,42 +324,6 @@ fun Component.toJSON(): ComponentJSON { ) } -/** Converts a [Node] into its JSON representation. */ -fun Node.toJSON(noEdges: Boolean = false): NodeJSON { - return NodeJSON( - id = this.id, - type = this.javaClass.simpleName, - startLine = location?.region?.startLine ?: -1, - startColumn = location?.region?.startColumn ?: -1, - endLine = location?.region?.endLine ?: -1, - endColumn = location?.region?.endColumn ?: -1, - code = this.code ?: "", - name = this.name.toString(), - fileName = - this.location?.artifactLocation?.uri?.let { uri -> - // Extract filename from URI - val path = uri.toString() - path.substringAfterLast('/').substringAfterLast('\\') - }, - astChildren = - if (noEdges) emptyList() - else (this as? AstNode)?.astChildren?.map { it.toJSON() } ?: emptyList(), - prevDFG = if (noEdges) emptyList() else this.prevDFGEdges.map { it.toJSON() }, - nextDFG = if (noEdges) emptyList() else this.nextDFGEdges.map { it.toJSON() }, - translationUnitId = this.translationUnit?.id, - componentName = this.component?.name?.toString(), - ) -} - -/** Converts an [Edge] into its JSON representation. */ -fun Edge<*>.toJSON(): EdgeJSON { - return EdgeJSON( - label = this.labels.firstOrNull() ?: "", - start = this.start.id, - end = this.end.id, - ) -} - /** Converts an [Assumption] into its JSON representation. */ fun Assumption.toJSON(): AssumptionJSON { return AssumptionJSON( @@ -461,23 +392,6 @@ val ArtifactLocation.absolutePath: Path? } } -/** - * Custom serializer for [Uuid] to convert it to and from a string representation. This is used for - * serialization and deserialization of [Uuid] in the JSON data classes. - */ -object UuidSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("Uuid", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Uuid) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): Uuid { - return Uuid.parse(decoder.decodeString()) - } -} - /** Converts the requirement categories of an [AnalysisProject] into their JSON representation. */ fun AnalysisProject.requirementCategoriesToJSON( requirementsResults: Map>? = null diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Router.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Router.kt index a210f4f63fe..1a247302fa3 100644 --- a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Router.kt +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/Router.kt @@ -25,6 +25,9 @@ */ package de.fraunhofer.aisec.codyze.console +import de.fraunhofer.aisec.codyze.console.ai.ChatRequestJSON +import de.fraunhofer.aisec.codyze.console.ai.ChatService +import de.fraunhofer.aisec.codyze.console.ai.McpServerHelper import de.fraunhofer.aisec.cpg.graph.concepts.Concept import de.fraunhofer.aisec.cpg.graph.listOverlayClasses import io.ktor.http.* @@ -245,6 +248,9 @@ fun Routing.apiRoutes(service: ConsoleService) { } } + // Feature flags endpoint + get("/features") { call.respond(mapOf("mcpEnabled" to McpServerHelper.isEnabled)) } + // The endpoint to get a QueryTree with its parent IDs for tree expansion get("/querytrees/{queryTreeId}/parents") { val queryTreeId = @@ -264,6 +270,45 @@ fun Routing.apiRoutes(service: ConsoleService) { } } +/** Chat and MCP routes — only registered when [ChatService] is available. */ +fun Route.chatRoutes(chatService: ChatService) { + route("/api/chat") { + post { + val request = call.receive() + call.respondTextWriter(contentType = ContentType.Text.EventStream) { + try { + chatService.chat(request).collect { chunk -> + try { + chunk.split("\n").forEach { line -> write("data: $line\n") } + write("\n") + flush() + } catch (e: io.ktor.utils.io.ClosedWriteChannelException) { + throw kotlinx.coroutines.CancellationException("Client disconnected", e) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + // Expected when client disconnects + } catch (e: Exception) { + e.printStackTrace() + try { + write("data: ERROR: ${e.message}\n\n") + flush() + } catch (ignored: Exception) {} + } + } + } + + get("/mcp/capabilities") { call.respond(chatService.getMcpCapabilities()) } + + post("/mcp/prompts/{name}") { + val name = + call.parameters["name"] ?: return@post call.respond(HttpStatusCode.BadRequest) + val arguments = call.receiveNullable>() ?: emptyMap() + call.respond(chatService.getPrompt(name, arguments)) + } + } +} + /** * This function sets up the static resources for the web application. It serves the static files * from the "static" directory and sets the default resource to "index.html". This serves our SPA diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/ChatClient.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/ChatClient.kt new file mode 100644 index 00000000000..0943c6f112b --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/ChatClient.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai + +import de.fraunhofer.aisec.codyze.console.ai.clients.* +import io.ktor.client.* +import io.modelcontextprotocol.kotlin.sdk.client.Client as McpSdkClient +import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions +import io.modelcontextprotocol.kotlin.sdk.client.StreamableHttpClientTransport +import io.modelcontextprotocol.kotlin.sdk.types.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.serialization.json.* + +class ChatClient( + private val httpClient: HttpClient, + private val llm: LlmClient, + private val mcpServerUrl: String, +) { + + private val mcp: McpSdkClient = + McpSdkClient( + clientInfo = Implementation(name = "codyze-client", version = "1.0.0"), + options = ClientOptions(), + ) + + private var tools: List = emptyList() + private var prompts: List = emptyList() + private var resources: List = emptyList() + + /** Connect to the MCP server via streamable HTTP. */ + suspend fun connect() { + val transport = StreamableHttpClientTransport(url = mcpServerUrl, client = httpClient) + mcp.connect(transport) + tools = mcp.listTools().tools + prompts = mcp.listPrompts().prompts + resources = mcp.listResources().resources + } + + /** Return the MCP capabilities: tools, prompts, and resources. */ + fun getMcpCapabilities(): McpCapabilitiesJSON = + McpCapabilitiesJSON( + serverName = mcp.serverVersion?.name ?: "MCP Server", + serverVersion = mcp.serverVersion?.version ?: "", + tools = + tools.map { tool -> + ToolInfoJSON( + name = tool.name, + description = tool.description, + inputSchema = + ToolSchemaJSON( + properties = tool.inputSchema.properties, + required = tool.inputSchema.required, + ), + ) + }, + prompts = + prompts.map { prompt -> + PromptInfoJSON( + name = prompt.name, + description = prompt.description, + arguments = + prompt.arguments?.map { arg -> + PromptArgumentJSON( + name = arg.name, + description = arg.description, + required = arg.required, + ) + }, + ) + }, + resources = + resources.map { resource -> + ResourceInfoJSON( + uri = resource.uri, + name = resource.name, + description = resource.description, + mimeType = resource.mimeType, + ) + }, + ) + + /** Resolve an MCP prompt and return its messages as [ChatMessageJSON]. */ + suspend fun getPrompt( + name: String, + arguments: Map = emptyMap(), + ): List { + val result = + mcp.getPrompt( + GetPromptRequest( + GetPromptRequestParams(name = name, arguments = arguments.ifEmpty { null }) + ) + ) + return result.messages.map { msg -> + ChatMessageJSON( + role = if (msg.role == Role.User) "user" else "assistant", + content = (msg.content as? TextContent)?.text ?: "", + ) + } + } + + /** Maximum number of tool call iterations before forcing a text response. */ + private val maxToolIterations = 8 + + /** Process a chat query using the LLM with MCP tool support */ + fun chat(request: ChatRequestJSON): Flow = channelFlow { + // Used if the LLM needs more time for a "cold-start" + send(Events.keepalive()) + + val userMessage = request.messages.lastOrNull()?.content ?: "" + val conversationHistory = request.messages + + try { + val maxAgentSteps = mutableListOf>() + var counter = 0 + + var toolCalls = + llm.sendPrompt( + userMessage = userMessage, + conversationHistory = conversationHistory, + tools = tools, + onText = { text -> send(Events.text(text)) }, + onReasoning = { thought -> send(Events.reasoning(thought)) }, + ) + println( + "Agent- Initial sendPrompt returned ${toolCalls.size} tool calls: ${toolCalls.map { it.name }}" + ) + + while (toolCalls.isNotEmpty() && counter < maxToolIterations) { + counter++ + println("Agent- Round $counter: executing ${toolCalls.map { it.name }}") + val roundtripResults = + toolCalls.map { toolCall -> + val result = executeToolCall(toolCall) { jsonEvent -> send(jsonEvent) } + ToolCallWithResult(toolCall, result) + } + maxAgentSteps.add(roundtripResults) + + toolCalls = + llm.sendPrompt( + userMessage = userMessage, + conversationHistory = conversationHistory, + maxAgentSteps = maxAgentSteps, + tools = tools, + onText = { text -> send(Events.text(text)) }, + onReasoning = { thought -> send(Events.reasoning(thought)) }, + ) + } + } catch (e: Exception) { + println("ChatClient.chat - Error: ${e.message}") + send(Events.text("Error: ${e.message}")) + } + } + + /** Execute a tool call and emit result to frontend */ + private suspend fun executeToolCall( + toolCall: ToolCall, + emit: suspend (String) -> Unit, + ): String { + return try { + val jsonArgs = Json.parseToJsonElement(toolCall.arguments).jsonObject + val arguments: Map = jsonArgs.toMap() + + val result = mcp.callTool(name = toolCall.name, arguments = arguments) + val contentTexts = result.content.mapNotNull { (it as? TextContent)?.text } + val resultText = contentTexts.joinToString("\n") + + val content = buildToolContentPayload(contentTexts) + val event = Events.toolResult(toolCall.name, content) + println("Tool - Emitting event: $event") + emit(event) + + resultText + } catch (e: Exception) { + val errorMsg = "Tool failed: ${e.message}" + emit(Events.text(errorMsg)) + errorMsg + } + } +} + +// Helper to convert JsonObject to Map +private fun JsonObject.toMap(): Map { + return this.mapValues { (_, value) -> + when (value) { + is JsonPrimitive -> { + when { + value.isString -> value.content + value.booleanOrNull != null -> value.boolean + value.intOrNull != null -> value.int + value.longOrNull != null -> value.long + value.doubleOrNull != null -> value.double + else -> value.content + } + } + is JsonObject -> value.toMap() + is JsonArray -> + value.map { + when (it) { + is JsonPrimitive -> it.content + is JsonObject -> it.toMap() + is JsonArray -> it.toString() + is JsonNull -> null + } + } + is JsonNull -> null + } + } +} + +private fun buildToolContentPayload(contentTexts: List): JsonElement { + if (contentTexts.isEmpty()) { + return JsonArray(emptyList()) + } + + val parsedItems = + contentTexts.map { text -> + try { + Json.parseToJsonElement(text) + } catch (_: Exception) { + JsonPrimitive(text) + } + } + + return if (parsedItems.size == 1) parsedItems[0] else JsonArray(parsedItems) +} diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/ChatService.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/ChatService.kt new file mode 100644 index 00000000000..3f9d78e61c4 --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/ChatService.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai + +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory +import de.fraunhofer.aisec.codyze.console.ai.clients.GeminiClient +import de.fraunhofer.aisec.codyze.console.ai.clients.LlmClient +import de.fraunhofer.aisec.codyze.console.ai.clients.OpenAiClient +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.sse.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.Json + +/** ChatService manages LLM client configuration and provides an API for chat interactions. */ +class ChatService(config: Config) { + private val llmProvider: String = config.getString("llm.client") + private val llmModel: String = config.getString("llm.$llmProvider.model") + private val llmBaseUrl: String = config.getString("llm.$llmProvider.baseUrl") + private val mcpServerUrl: String = config.getString("mcp.serverUrl") + + private val httpClient = + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + isLenient = true + } + ) + } + install(SSE) + install(HttpTimeout) { + requestTimeoutMillis = 600_000 + connectTimeoutMillis = 30_000 + socketTimeoutMillis = 600_000 + } + } + + private val llmClient: LlmClient = + when (llmProvider) { + "gemini" -> { + val apiKey = + System.getenv("GEMINI_API_KEY") + ?: throw IllegalStateException("GEMINI_API_KEY not set") + GeminiClient(httpClient, llmModel, apiKey, llmBaseUrl) + } + else -> OpenAiClient(httpClient, llmModel, llmBaseUrl) + } + + private val chatClient: ChatClient = + ChatClient(httpClient = httpClient, llm = llmClient, mcpServerUrl = mcpServerUrl) + + suspend fun connect() { + chatClient.connect() + } + + fun chat(request: ChatRequestJSON): Flow { + return chatClient.chat(request) + } + + fun getMcpCapabilities(): McpCapabilitiesJSON = chatClient.getMcpCapabilities() + + suspend fun getPrompt(name: String, arguments: Map): List = + chatClient.getPrompt(name, arguments) + + fun close() { + httpClient.close() + } + + companion object { + fun createIfConfigExist(): ChatService? { + val config = ConfigFactory.load() + if (!config.hasPath("llm.client")) { + println( + "No application.conf found, AI chat features disabled. " + + "Copy application.conf.example to application.conf to enable them." + ) + return null + } + return ChatService(config) + } + } +} diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/KoogChatClient.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/KoogChatClient.kt new file mode 100644 index 00000000000..e3f1ee99186 --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/KoogChatClient.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2026, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai + +import ai.koog.agents.core.agent.AIAgent +import ai.koog.agents.core.agent.config.AIAgentConfig +import ai.koog.agents.core.agent.functionalStrategy +import ai.koog.agents.core.dsl.extension.asAssistantMessage +import ai.koog.agents.core.dsl.extension.containsToolCalls +import ai.koog.agents.core.dsl.extension.executeMultipleTools +import ai.koog.agents.core.dsl.extension.extractToolCalls +import ai.koog.agents.core.dsl.extension.requestLLMMultiple +import ai.koog.agents.core.dsl.extension.sendMultipleToolResults +import ai.koog.agents.features.eventHandler.feature.EventHandler +import ai.koog.agents.mcp.McpToolRegistryProvider +import ai.koog.prompt.dsl.prompt +import ai.koog.prompt.executor.clients.google.GoogleModels +import ai.koog.prompt.executor.llms.SingleLLMPromptExecutor +import ai.koog.prompt.executor.llms.all.simpleGoogleAIExecutor +import ai.koog.prompt.executor.ollama.client.OllamaClient +import ai.koog.prompt.llm.LLMCapability +import ai.koog.prompt.llm.LLMProvider +import ai.koog.prompt.llm.LLModel +import ai.koog.prompt.streaming.StreamFrame +import de.fraunhofer.aisec.codyze.console.ai.clients.Events +import de.fraunhofer.aisec.codyze.console.ai.clients.SYSTEM_PROMPT +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive + +class KoogChatClient( + private val provider: String, + private val model: String, + private val baseUrl: String, + private val mcpServerUrl: String, +) { + fun chat(request: ChatRequestJSON): Flow = channelFlow { + send(Events.keepalive()) + + val userMessage = request.messages.lastOrNull()?.content ?: return@channelFlow + + try { + val executor = + when (provider) { + "gemini" -> { + val key = + System.getenv("GEMINI_API_KEY") + ?: throw IllegalStateException("GEMINI_API_KEY not set") + simpleGoogleAIExecutor(key) + } + + else -> SingleLLMPromptExecutor(OllamaClient(baseUrl = baseUrl)) + } + + val llmModel: LLModel = + when (provider) { + "gemini" -> GoogleModels.Gemini2_5Flash + else -> + LLModel( + provider = LLMProvider.Ollama, + id = model, + capabilities = listOf(LLMCapability.Temperature, LLMCapability.Tools), + contextLength = 8192L, + ) + } + + val toolRegistry = + McpToolRegistryProvider.fromTransport( + McpToolRegistryProvider.defaultSseTransport(mcpServerUrl) + ) + + val agentConfig = + AIAgentConfig( + prompt = + prompt("codyze-agent") { + system(SYSTEM_PROMPT) + request.messages.dropLast(1).forEach { msg -> + when (msg.role) { + "user" -> user(msg.content) + "assistant" -> assistant(msg.content) + } + } + }, + model = llmModel, + maxAgentIterations = 10, + ) + + // TODO: proof whether we need another strategy. + // Basic strategy taken from https://docs.koog.ai/functional-agents/ + // Note: It seems that we can just use the predefined `chatAgentStrategy` + // https://docs.koog.ai/predefined-agent-strategies/#2-reasoning + // chatAgentStrategy() is only useful for tool calling without chatting in plain text. + val agentStrategy = + functionalStrategy { input -> + // Send the user input to the LLM + var responses = requestLLMMultiple(input) + // Only loop while the LLM requests tools + while (responses.containsToolCalls()) { + val pendingCalls = extractToolCalls(responses) + val results = executeMultipleTools(pendingCalls) + // Send the tool results back to the LLM. The LLM may call more tools or + // return a final output + responses = sendMultipleToolResults(results) + } + // When no tool calls remain, extract and return the assistant message content + // from the response + responses.single().asAssistantMessage().content + } + + val streamChannel = this.channel + val agent = + AIAgent( + promptExecutor = executor, + strategy = agentStrategy, + agentConfig = agentConfig, + toolRegistry = toolRegistry, + ) { + install(EventHandler) { + onLLMStreamingFrameReceived { ctx -> + val frame = ctx.streamFrame + if (frame is StreamFrame.Append && frame.text.isNotEmpty()) { + streamChannel.trySend(Events.text(frame.text)) + } + } + onToolCallStarting { ctx -> + streamChannel.trySend(Events.text("\n[Tool: ${ctx.toolName}]\n")) + } + onToolCallCompleted { ctx -> + val resultStr = ctx.toolResult.toString() + if (resultStr.isNotEmpty()) { + val content = + try { + Json.parseToJsonElement(resultStr) + } catch (_: Exception) { + JsonPrimitive(resultStr) + } + streamChannel.trySend(Events.toolResult(ctx.toolName, content)) + } + } + } + } + println("Registered tools: ${toolRegistry.tools.map { it.name }}") + agent.run(userMessage) + } catch (e: Exception) { + send(Events.text("Error: ${e.message}")) + } + } +} diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/McpModels.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/McpModels.kt new file mode 100644 index 00000000000..7432f79ec9d --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/McpModels.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable data class ChatMessageJSON(val role: String, val content: String) + +@Serializable data class ChatRequestJSON(val messages: List) + +@Serializable data class ToolSchemaJSON(val properties: JsonObject?, val required: List?) + +@Serializable +data class ToolInfoJSON( + val name: String, + val description: String?, + val inputSchema: ToolSchemaJSON?, +) + +@Serializable +data class PromptArgumentJSON(val name: String, val description: String?, val required: Boolean?) + +@Serializable +data class PromptInfoJSON( + val name: String, + val description: String?, + val arguments: List?, +) + +@Serializable +data class ResourceInfoJSON( + val uri: String, + val name: String?, + val description: String?, + val mimeType: String?, +) + +@Serializable +data class McpCapabilitiesJSON( + val serverName: String, + val serverVersion: String, + val tools: List, + val prompts: List, + val resources: List, +) diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/McpServerHelper.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/McpServerHelper.kt new file mode 100644 index 00000000000..91ff3018bf1 --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/McpServerHelper.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai + +/** + * Helper object to make the features of the `cpg-mcp` module conditionally available. When the + * module is available in the build, this will use the actual MCP functions directly. + */ +object McpServerHelper { + /** Check if mcp module is enabled */ + val isEnabled: Boolean by lazy { + try { + Class.forName("de.fraunhofer.aisec.cpg.mcp.ApplicationKt") + true + } catch (_: ClassNotFoundException) { + false + } + } + + fun startMcpServer(port: Int) { + if (!isEnabled) { + return + } + + try { + println("Starting MCP server with streamable HTTP on port $port...") + val mcpServerKt = Class.forName("de.fraunhofer.aisec.cpg.mcp.mcpserver.McpServerKt") + val server = mcpServerKt.getMethod("configureDefaultServer").invoke(null) + + val appKt = Class.forName("de.fraunhofer.aisec.cpg.mcp.ApplicationKt") + val runServer = appKt.methods.first { it.name == "runHttpMcpServerUsingKtorPlugin" } + runServer.invoke(null, port, "0.0.0.0", server, false) + } catch (e: Exception) { + println("Failed to start MCP server: ${e.message}") + e.printStackTrace() + } + } + + /** Set the global analysis result in the `cpg-mcp` module */ + fun setGlobalAnalysisResult(result: de.fraunhofer.aisec.cpg.TranslationResult) { + if (!isEnabled) { + return + } + + try { + val toolsClass = + Class.forName("de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.CpgAnalyzeToolKt") + val setResult = toolsClass.getMethod("setGlobalAnalysisResult", result.javaClass) + setResult.invoke(null, result) + } catch (e: Exception) { + println("Warning: Failed to set globalAnalysisResult in cpg-mcp module: ${e.message}") + e.printStackTrace() + } + } +} diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/ClientModels.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/ClientModels.kt new file mode 100644 index 00000000000..d119713f75b --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/ClientModels.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai.clients + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +@Serializable +data class GeminiRequest( + val contents: List, + val systemInstruction: GeminiContent? = null, + val tools: List? = null, +) + +@Serializable data class GeminiContent(val role: String? = null, val parts: List) + +@Serializable +data class GeminiPart( + val text: String? = null, + val functionCall: GeminiFunctionCall? = null, + val functionResponse: GeminiFunctionResponse? = null, +) + +@Serializable data class GeminiFunctionCall(val name: String, val args: JsonObject) + +@Serializable data class GeminiFunctionResponse(val name: String, val response: JsonObject) + +@Serializable data class GeminiTools(val functionDeclarations: List) + +@Serializable +data class GeminiFunctionDef(val name: String, val description: String, val parameters: JsonObject) + +data class ToolCall(val name: String, val arguments: String) + +data class ToolCallWithResult(val call: ToolCall, val result: String) + +data class StreamingToolCall( + var id: String? = null, + var name: String? = null, + var arguments: String = "", +) + +@Serializable +data class OpenAiRequest( + val model: String, + val messages: List, + val tools: List? = null, + val stream: Boolean = false, + @SerialName("max_tokens") val maxTokens: Int? = null, +) + +@Serializable +data class OpenAiMessage( + val role: String, + val content: JsonElement? = null, + @SerialName("tool_calls") val toolCalls: List? = null, + @SerialName("tool_call_id") val toolCallId: String? = null, +) + +@Serializable +data class OpenAiToolCall( + val id: String, + @kotlinx.serialization.Required val type: String = "function", + val function: OpenAiFunctionCall, +) + +@Serializable data class OpenAiFunctionCall(val name: String, val arguments: String) + +@Serializable data class OpenAiTool(val type: String, val function: OpenAiFunctionDef) + +@Serializable +data class OpenAiFunctionDef(val name: String, val description: String, val parameters: JsonObject) diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/GeminiClient.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/GeminiClient.kt new file mode 100644 index 00000000000..a0c19040ffb --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/GeminiClient.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai.clients + +import de.fraunhofer.aisec.codyze.console.ai.ChatMessageJSON +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.utils.io.* +import io.modelcontextprotocol.kotlin.sdk.types.Tool +import kotlinx.serialization.json.* + +class GeminiClient( + private val httpClient: HttpClient, + private val model: String, + private val apiKey: String, + private val baseUrl: String, +) : LlmClient { + override val modelName: String = model + + override suspend fun sendPrompt( + userMessage: String, + conversationHistory: List, + tools: List, + maxAgentSteps: List>?, + onText: suspend (String) -> Unit, + onReasoning: suspend (String) -> Unit, + ): List { + val toolCalls = mutableListOf() + + val geminiTools = + if (tools.isNotEmpty()) { + listOf( + GeminiTools( + functionDeclarations = + tools.map { tool -> + GeminiFunctionDef( + name = tool.name, + description = tool.description ?: "", + parameters = + buildJsonObject { + put("type", "object") + put( + "properties", + tool.inputSchema.properties ?: buildJsonObject {}, + ) + }, + ) + } + ) + ) + } else null + + val historyContents = buildList { + conversationHistory.dropLast(1).forEach { msg -> + if (msg.content.isNotBlank()) { + val role = if (msg.role == "assistant") "model" else "user" + add(GeminiContent(role = role, parts = listOf(GeminiPart(text = msg.content)))) + } + } + } + + val contents = + if (maxAgentSteps != null) { + historyContents + + listOf( + GeminiContent(role = "user", parts = listOf(GeminiPart(text = userMessage))) + ) + + maxAgentSteps.flatMap { roundtrip -> + listOf( + GeminiContent( + role = "model", + parts = + roundtrip.map { tool -> + GeminiPart( + functionCall = + GeminiFunctionCall( + name = tool.call.name, + args = + Json.parseToJsonElement(tool.call.arguments) + .jsonObject, + ) + ) + }, + ), + GeminiContent( + role = "user", + parts = + roundtrip.map { tool -> + GeminiPart( + functionResponse = + GeminiFunctionResponse( + name = tool.call.name, + response = + buildJsonObject { + put("result", tool.result) + }, + ) + ) + }, + ), + ) + } + } else { + historyContents + + listOf( + GeminiContent(role = "user", parts = listOf(GeminiPart(text = userMessage))) + ) + } + + val request = + GeminiRequest( + systemInstruction = GeminiContent(parts = listOf(GeminiPart(text = SYSTEM_PROMPT))), + contents = contents, + tools = geminiTools, + ) + + httpClient + .preparePost("$baseUrl/models/$model:streamGenerateContent?alt=sse&key=$apiKey") { + contentType(ContentType.Application.Json) + setBody(request) + } + .execute { response -> + if (response.status.value >= 400) { + val errorBody = response.body() + onText("Gemini API error (${response.status}): $errorBody") + return@execute + } + + val channel = response.body() + streamMessages(channel, onText, toolCalls) + } + + return toolCalls + } + + private suspend fun streamMessages( + channel: ByteReadChannel, + onText: suspend (String) -> Unit, + toolCalls: MutableList, + ) { + readSseStream(channel) { jsonStr -> + val chunk = Json.parseToJsonElement(jsonStr).jsonObject + val parts = + chunk["candidates"] + ?.jsonArray + ?.firstOrNull() + ?.jsonObject + ?.get("content") + ?.jsonObject + ?.get("parts") + ?.jsonArray + + parts?.forEach { part -> + val partJSON = part.jsonObject + + partJSON["text"]?.jsonPrimitive?.contentOrNull?.let { text -> + if (text.isNotEmpty()) onText(text) + } + + partJSON["functionCall"]?.jsonObject?.let { functionCall -> + val name = functionCall["name"]?.jsonPrimitive?.contentOrNull + val args = functionCall["args"]?.jsonObject + if (name != null) { + toolCalls.add(ToolCall(name, args?.toString() ?: "{}")) + } + } + } + } + } +} diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/LlmClient.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/LlmClient.kt new file mode 100644 index 00000000000..d36506deb53 --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/LlmClient.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai.clients + +import de.fraunhofer.aisec.codyze.console.ai.ChatMessageJSON +import io.modelcontextprotocol.kotlin.sdk.types.Tool + +/** Interface abstracting the underlying LLM provider (Gemini, OpenAI, Ollama, etc.). */ +interface LlmClient { + val modelName: String + + /** + * Streaming prompt execution for the chat. Calls [onText] for normal content and [onReasoning] + * for thoughts/reasoning. + */ + suspend fun sendPrompt( + userMessage: String, + conversationHistory: List = emptyList(), + tools: List = emptyList(), + maxAgentSteps: List>? = null, + onText: suspend (String) -> Unit, + onReasoning: suspend (String) -> Unit = {}, + ): List +} diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/OpenAiClient.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/OpenAiClient.kt new file mode 100644 index 00000000000..e56d481593e --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/OpenAiClient.kt @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai.clients + +import de.fraunhofer.aisec.codyze.console.ai.ChatMessageJSON +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.utils.io.* +import io.modelcontextprotocol.kotlin.sdk.types.Tool +import kotlinx.serialization.json.* + +/** OpenAI-compatible API client (Ollama, vLLM, MLX) */ +class OpenAiClient( + private val httpClient: HttpClient, + private val model: String, + private val baseUrl: String, +) : LlmClient { + override val modelName: String = model + + override suspend fun sendPrompt( + userMessage: String, + conversationHistory: List, + tools: List, + maxAgentSteps: List>?, + onText: suspend (String) -> Unit, + onReasoning: suspend (String) -> Unit, + ): List { + val collectedToolCalls = mutableListOf() + val accumulatedToolCalls = mutableListOf() + + var callIdCounter = 0 + + val messages = buildList { + add(OpenAiMessage(role = "system", content = JsonPrimitive(SYSTEM_PROMPT))) + + conversationHistory.dropLast(1).forEach { msg -> + if (msg.content.isNotBlank()) { + add(OpenAiMessage(role = msg.role, content = JsonPrimitive(msg.content))) + } + } + + add(OpenAiMessage(role = "user", content = JsonPrimitive(userMessage))) + + if (maxAgentSteps != null) { + for (agentStep in maxAgentSteps) { + val startId = callIdCounter + add( + OpenAiMessage( + role = "assistant", + content = JsonPrimitive(""), + toolCalls = + agentStep.mapIndexed { index, tr -> + OpenAiToolCall( + id = "call_${startId + index}", + type = "function", + function = + OpenAiFunctionCall( + name = tr.call.name, + arguments = tr.call.arguments, + ), + ) + }, + ) + ) + agentStep.forEachIndexed { index, tr -> + add( + OpenAiMessage( + role = "tool", + toolCallId = "call_${startId + index}", + content = JsonPrimitive(tr.result), + ) + ) + } + callIdCounter += agentStep.size + } + } + } + + val openAiTools = + if (tools.isNotEmpty()) { + tools.map { tool -> + OpenAiTool( + type = "function", + function = + OpenAiFunctionDef( + name = tool.name, + description = tool.description ?: "", + parameters = + buildJsonObject { + put("type", "object") + put( + "properties", + tool.inputSchema.properties ?: buildJsonObject {}, + ) + tool.inputSchema.required?.let { required -> + put( + "required", + JsonArray(required.map { r -> JsonPrimitive(r) }), + ) + } + }, + ), + ) + } + } else null + + val request = + OpenAiRequest(model = model, messages = messages, tools = openAiTools, stream = true) + + httpClient + .preparePost("$baseUrl/v1/chat/completions") { + contentType(ContentType.Application.Json) + setBody(request) + } + .execute { response -> + if (!response.status.isSuccess()) { + val errorBody = response.body() + onText("LLM request failed: ${response.status.value}\n$errorBody") + return@execute + } + val channel = response.body() + streamMessages(channel, onText, onReasoning, accumulatedToolCalls) + } + + for (tcObj in accumulatedToolCalls) { + val function = tcObj["function"]?.jsonObject + val name = function?.get("name")?.jsonPrimitive?.contentOrNull + val args = function?.get("arguments")?.jsonPrimitive?.contentOrNull ?: "{}" + if (name != null) { + collectedToolCalls.add(ToolCall(name, args)) + } + } + + return collectedToolCalls + } + + private suspend fun streamMessages( + channel: ByteReadChannel, + onText: suspend (String) -> Unit, + onReasoning: suspend (String) -> Unit, + accumulatedToolCalls: MutableList, + ) { + val streamingToolCalls = mutableMapOf() + + readSseStream(channel) { jsonStr -> + val chunk = Json.parseToJsonElement(jsonStr).jsonObject + val delta = + chunk["choices"]?.jsonArray?.firstOrNull()?.jsonObject?.get("delta")?.jsonObject + + if (delta != null) { + val reasoningContent = + delta["reasoning"]?.jsonPrimitive?.contentOrNull + ?: delta["thoughts"]?.jsonPrimitive?.contentOrNull + ?: delta["thinking"]?.jsonPrimitive?.contentOrNull + + if (reasoningContent?.isNotEmpty() == true) { + onReasoning(reasoningContent) + } + + delta["content"]?.jsonPrimitive?.contentOrNull?.let { content -> + if (content.isNotEmpty()) { + onText(content) + } + } + + delta["tool_calls"]?.jsonArray?.forEach { tool -> + val toolJSON = tool.jsonObject + val index = toolJSON["index"]?.jsonPrimitive?.intOrNull ?: 0 + val entry = streamingToolCalls.getOrPut(index) { StreamingToolCall() } + + toolJSON["id"]?.jsonPrimitive?.contentOrNull?.let { entry.id = it } + toolJSON["function"]?.jsonObject?.let { func -> + func["name"]?.jsonPrimitive?.contentOrNull?.let { entry.name = it } + func["arguments"]?.jsonPrimitive?.contentOrNull?.let { + entry.arguments += it + } + } + } + } + } + + streamingToolCalls.values.forEach { entry -> + if (entry.name != null) { + accumulatedToolCalls.add( + buildJsonObject { + entry.id?.let { put("id", it) } + put("type", "function") + put( + "function", + buildJsonObject { + put("name", entry.name!!) + put("arguments", entry.arguments) + }, + ) + } + ) + } + } + } +} diff --git a/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/Util.kt b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/Util.kt new file mode 100644 index 00000000000..c4436c15442 --- /dev/null +++ b/codyze-console/src/main/kotlin/de/fraunhofer/aisec/codyze/console/ai/clients/Util.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.codyze.console.ai.clients + +import de.fraunhofer.aisec.codyze.console.ai.ChatClient +import io.ktor.utils.io.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +const val SYSTEM_PROMPT = + "You are a code analysis agent with access to CPG (Code Property Graph) tools. " + + "You have ONLY the tools listed in the tool definitions — do NOT invent or guess tool names. " + + "When the user asks a question about the code, use your tools immediately do not ask for permission or confirmation and also don't ask the user " + + "to do the analysis themselves. " + + "Follow a multi-step approach: " + + "1) First you can use listing tools to get an overview (e.g., cpg_list_functions for function summaries with names, parameters, and callees). " + + "2) Then use cpg_get_node with specific IDs to inspect the actual source code of functions that look relevant. " + + "Do NOT stop at summaries alone — always retrieve and inspect the code before drawing conclusions. " + + "When inspecting a node with cpg_get_node, the result includes prevDFG/nextDFG edges showing data flow connections. " + + "If you can answer from previous tool results already in the conversation, you can response without calling tools again. " + + "If a tool call fails, do NOT retry the same call — instead, answer the question using the information you already have from previous tool results and your own knowledge. " + + "Explain your findings clearly." + +suspend fun readSseStream(channel: ByteReadChannel, processLine: suspend (String) -> Unit) { + while (!channel.isClosedForRead) { + val line = + try { + channel.readUTF8Line() + } catch (_: Exception) { + break + } + + if (line.isNullOrBlank()) continue + if (!line.startsWith("data: ")) continue + + val jsonStr = line.substringAfter("data: ").trim() + if (jsonStr == "[DONE]") break + + try { + processLine(jsonStr) + } catch (_: Exception) { + // Continue on parsing errors + } + } +} + +/** SSE event payloads streamed from [ChatClient] to the frontend. */ +object Events { + fun text(content: String): String = + Json.encodeToString( + buildJsonObject { + put("type", "text") + put("content", content) + } + ) + + fun reasoning(content: String): String = + Json.encodeToString( + buildJsonObject { + put("type", "reasoning") + put("content", content) + } + ) + + fun keepalive(): String = Json.encodeToString(buildJsonObject { put("type", "keepalive") }) + + fun toolResult(toolName: String, content: JsonElement): String = + Json.encodeToString( + buildJsonObject { + put("type", "tool_result") + put("toolName", toolName) + put("content", content) + } + ) +} diff --git a/codyze-console/src/main/resources/.gitignore b/codyze-console/src/main/resources/.gitignore index 6499bf4a1bb..fbeccbc6634 100644 --- a/codyze-console/src/main/resources/.gitignore +++ b/codyze-console/src/main/resources/.gitignore @@ -2,4 +2,5 @@ static node_modules .vite.deps .vite +application.conf diff --git a/codyze-console/src/main/resources/application.conf.example b/codyze-console/src/main/resources/application.conf.example new file mode 100644 index 00000000000..ea361f07432 --- /dev/null +++ b/codyze-console/src/main/resources/application.conf.example @@ -0,0 +1,33 @@ +llm { + client = "ollama" # Options: "ollama", "gemini", "vLLM" + + gemini { + baseUrl = "https://generativelanguage.googleapis.com/v1beta" + model = "gemini-2.5-flash" + } + + ollama { + baseUrl = "http://localhost:11434" + model = "llama3" + } + + mlx { + baseUrl = "http://localhost:8000" + model = "your-mlx-model" + } + + local { + baseUrl = "http://localhost:1234" + model = "your-local-model" + } + + vLLM { + baseUrl = "http://localhost:8000" + model = "your-vllm-model" + } +} + +mcp { + # MCP Server Configuration + serverUrl = "http://localhost:8081/mcp" +} \ No newline at end of file diff --git a/codyze-console/src/main/webapp/package.json b/codyze-console/src/main/webapp/package.json index 8cb510410f2..a6453d0d553 100644 --- a/codyze-console/src/main/webapp/package.json +++ b/codyze-console/src/main/webapp/package.json @@ -41,7 +41,8 @@ "vite": "^6.4.1" }, "dependencies": { - "chart.js": "^4.5.1" + "chart.js": "^4.5.1", + "marked": "^16.3.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/codyze-console/src/main/webapp/pnpm-lock.yaml b/codyze-console/src/main/webapp/pnpm-lock.yaml index febdf0eb445..ee5805f256c 100644 --- a/codyze-console/src/main/webapp/pnpm-lock.yaml +++ b/codyze-console/src/main/webapp/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: chart.js: specifier: ^4.5.1 version: 4.5.1 + marked: + specifier: ^16.3.0 + version: 16.4.2 devDependencies: '@eslint/compat': specifier: ^1.4.1 @@ -1101,6 +1104,11 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + mini-svg-data-uri@1.4.4: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true @@ -2303,6 +2311,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@16.4.2: {} + mini-svg-data-uri@1.4.4: {} minimatch@10.2.4: diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/ChatInterface.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/ChatInterface.svelte new file mode 100644 index 00000000000..b9819bbdf23 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/ChatInterface.svelte @@ -0,0 +1,309 @@ + + +
+ +
+ +
+
+ {#each messages as message} + {#if message.role === 'user'} +
+
+
+
{message.content}
+
+
+
+ {:else} +
+ {#if message.reasoning} +
+ + {#if expandedReasoning.has(message.id)} +
+

{message.reasoning}

+
+ {/if} +
+ {/if} + {#if message.contentType === 'tool-result' && message.toolResult} + {#if message.toolResult.toolName === 'cpg_dfg_backward'} + + {:else if isCodeItemContent(message.toolResult.content)} + + {:else} +
+
+ {#if message.toolResult.toolName} + {message.toolResult.toolName} + {/if} + {#if message.toolResult.isError} + Error + {/if} +
+
{typeof message.toolResult.content === 'string' ? message.toolResult.content : JSON.stringify(message.toolResult.content, null, 2)}
+
+ {/if} + {:else if message.content} +
+ +
+ {/if} +
+ {/if} + {/each} + + {#if isLoading || displayContent} +
+ {#if displayContent} +
+ +
+ {:else} +
+
+
+
+
+
+
+ {/if} +
+ {/if} +
+
+ + +
+
+ + {#if mcpCapabilities && onOpenMcpModal} +
+ +
+ {/if} +
+
+
+ + + {#if showCodePanel && selectedTranslationUnit} +
+ + {#if selectedComponent()} + + {/if} + + + +
+ {/if} +
+ + \ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/MarkdownRenderer.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/MarkdownRenderer.svelte new file mode 100644 index 00000000000..011440b77b3 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/MarkdownRenderer.svelte @@ -0,0 +1,144 @@ + + +
+ {@html html} +
+ + diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/McpCapabilitiesModal.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/McpCapabilitiesModal.svelte new file mode 100644 index 00000000000..53d34488499 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/McpCapabilitiesModal.svelte @@ -0,0 +1,212 @@ + + + + + diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/MessageInput.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/MessageInput.svelte new file mode 100644 index 00000000000..2c3dac7a313 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/MessageInput.svelte @@ -0,0 +1,179 @@ + + +
+ + {#if showPicker} +
+
+

Available prompts

+
+
    + {#each filteredPrompts as prompt, i} +
  • + +
  • + {/each} +
+
+ {/if} + +
+ {#if onNewChat} + +
+ {/if} + + +
+
\ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/NotConfigured.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/NotConfigured.svelte new file mode 100644 index 00000000000..84919a7e2c2 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/NotConfigured.svelte @@ -0,0 +1,40 @@ + + +
+
+
+ + + +

Agent features not configured

+
+ +

+ The MCP module is enabled, but no configuration file was found. Copy + application.conf.example + to + application.conf + and configure the following, then restart the server: +

+ +
+
+

LLM Provider

+

Choose one of the supported providers and set the model and base URL:

+
    +
  • ollama, vLLM, or mlx — OpenAI-compatible endpoints, e.g. http://localhost:11434
  • +
  • gemini — requires GEMINI_API_KEY env variable
  • +
+
+ +
+

MCP Server

+

+ Set mcp.serverUrl — default is + http://localhost:8081/mcp. +

+
+
+
+
\ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/WelcomeScreen.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/WelcomeScreen.svelte new file mode 100644 index 00000000000..5eb039732a0 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/WelcomeScreen.svelte @@ -0,0 +1,104 @@ + + +
+ +
+
+

+ CodAIze Agent +
+
+ + + +
+
+

+
+
+ + +
+
+ {#each suggestedQuestions as question} + + {/each} +
+
+ + +
+ messageInput = value} + placeholder="Ask me anything about your codebase..." + prompts={mcpCapabilities?.prompts} + onPromptSelect={onPromptSelect} + /> + {#if mcpCapabilities && onOpenMcpModal} +
+ +
+ {/if} +
+
diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/index.ts b/codyze-console/src/main/webapp/src/lib/components/ai-agent/index.ts new file mode 100644 index 00000000000..27b70fe767a --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/index.ts @@ -0,0 +1,4 @@ +export { default as WelcomeScreen } from './WelcomeScreen.svelte'; +export { default as ChatInterface } from './ChatInterface.svelte'; +export { default as McpCapabilitiesModal } from './McpCapabilitiesModal.svelte'; +export { default as NotConfigured } from './NotConfigured.svelte'; diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/CodeItemList.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/CodeItemList.svelte new file mode 100644 index 00000000000..ccbaba9eeb8 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/CodeItemList.svelte @@ -0,0 +1,458 @@ + + + + +
+
+
+ + + + {widgetTitle} +
+ {items.length} result{items.length !== 1 ? 's' : ''} +
+ +
+ {#if items.length === 0} +
+

No results

+
+ {:else} + {#each visibleItems as item (item.id)} +
+ + + {#if item.expandable && expandedIds.has(item.id)} +
+ {#each Object.entries(item.extraProps) as [key, value]} +
+ {key}: + {#if key === 'Code'} +
{value}
+ {:else} +

{value}

+ {/if} +
+ {/each} + +
+ {/if} +
+ {/each} + + {#if hasMore && !showAll} + + {/if} + {/if} +
+
+ + diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/DfgFlowWidget.svelte b/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/DfgFlowWidget.svelte new file mode 100644 index 00000000000..094ce068f5c --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/DfgFlowWidget.svelte @@ -0,0 +1,63 @@ + + +{#if nodes.length === 0} +
No data flow found.
+{:else} +
+
+ + + + + Data flow (backward) +
+ +
+ {#each nodes as node, i} + {#if i > 0} + + {/if} +
+
+ {node.type ?? ''} + {#if node.startLine != null && node.startLine >= 0} + · + + {node.fileName ? `${node.fileName}:${node.startLine}` : `:${node.startLine}`} + + {/if} +
+
{node.name ?? '?'}
+ {#if node.code && node.code !== node.name} +
{node.code.trim()}
+ {/if} +
+ {/each} +
+
+{/if} \ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/widgetRegistry.ts b/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/widgetRegistry.ts new file mode 100644 index 00000000000..0b484e96b75 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/ai-agent/widgets/widgetRegistry.ts @@ -0,0 +1,5 @@ +export type ToolResultData = { + toolName?: string; + content: any; + isError?: boolean; +}; diff --git a/codyze-console/src/main/webapp/src/lib/components/analysis/CodeViewer.svelte b/codyze-console/src/main/webapp/src/lib/components/analysis/CodeViewer.svelte new file mode 100644 index 00000000000..2346b62480a --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/analysis/CodeViewer.svelte @@ -0,0 +1,240 @@ + + +
+ +
+
+
{translationUnit.name}
+
+ {#if headerActions} + {@render headerActions()} + {/if} + {#if onClose} + + {/if} + {#if !hideControls} + + + {/if} +
+
+ +
+
+ + + +
+ + {#if finding && highlightLine} + + {/if} + + +
+ +
+ + + {#if hideControls} + + {#if nodePanelCollapsed} + + + {:else} + +
+ +
+ (activeTab = id)} /> +
+
+ console.log('Node clicked:', node)} + /> +
+
+ {/if} + {:else} + {#if !nodePanelCollapsed} +
+
+ (activeTab = id)} /> +
+
+ console.log('Node clicked:', node)} + /> +
+
+ {/if} + {/if} +
+ + \ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/components/analysis/FileTree.svelte b/codyze-console/src/main/webapp/src/lib/components/analysis/FileTree.svelte new file mode 100644 index 00000000000..cbc86319dd5 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/components/analysis/FileTree.svelte @@ -0,0 +1,234 @@ + + +{#snippet TreeNodeSnippet(nodes: TreeNode[], depth = 0)} +
    + {#each nodes as node} +
  • + {#if node.type === 'component'} + + + + + {node.name} + {#if node.isCurrent} + Current + {/if} + + {#if node.isCurrent && node.children.length > 0} +
    + {@render TreeNodeSnippet(node.children, depth + 1)} +
    + {/if} + + {:else if node.type === 'folder'} + + {#if expandedFolders.has(node.path) || node.expanded} + {@render TreeNodeSnippet(node.children, depth + 1)} + {/if} + + {:else} + {#if fileHref} + + + + + {node.name} + + {:else} + + {/if} + {/if} +
  • + {/each} +
+{/snippet} + +{#if hideHeader} + {#if collapsed} + + + {:else} + +
+ + +
+ {/if} +{:else} +
+
+ {#if !collapsed} + Files + {/if} + +
+ {#if !collapsed} + + {/if} +
+{/if} \ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/components/analysis/FindingOverlay.svelte b/codyze-console/src/main/webapp/src/lib/components/analysis/FindingOverlay.svelte index 0707c258086..e598df84d0f 100644 --- a/codyze-console/src/main/webapp/src/lib/components/analysis/FindingOverlay.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/analysis/FindingOverlay.svelte @@ -11,7 +11,7 @@ let { finding, kind, line, lineHeight, offsetTop }: Props = $props(); - let { backgroundColor, borderColor, color } = getFindingStyle(kind); + const { backgroundColor, borderColor, color } = $derived(getFindingStyle(kind));
-
-

- {title} ({nodes.length}) -

-
- - - - - - - - - - - {#each nodes as node (node.id)} - + +
+ {title} + {nodes.length} +
+ + {#if nodes.length === 0} +

No nodes

+ {:else} +
    + {#each nodes as node (node.id)} +
  • +
- - - - - {/each} - -
- Type - - Name - - Location - - Code -
+ {node.type} - - {node.name} - - L{node.startLine}:C{node.startColumn} - L{node.endLine}:C - {node.endColumn} - - {node.code} -
-
-
+ + {node.name || '—'} + L{node.startLine} + + + {/each} + + {/if} +
\ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/components/analysis/QueryTreeExplorer.svelte b/codyze-console/src/main/webapp/src/lib/components/analysis/QueryTreeExplorer.svelte index 04ed8bab29f..fdda5861aa5 100644 --- a/codyze-console/src/main/webapp/src/lib/components/analysis/QueryTreeExplorer.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/analysis/QueryTreeExplorer.svelte @@ -31,10 +31,12 @@ baseUrl }: Props = $props(); - // Early return if queryTree is undefined - if (!queryTree) { - console.error('QueryTreeExplorer: queryTree prop is undefined'); - } + // Log if queryTree is undefined + $effect(() => { + if (!queryTree) { + console.error('QueryTreeExplorer: queryTree prop is undefined'); + } + }); // Use hasChildren from the QueryTree data structure const hasChildren = $derived( @@ -194,8 +196,11 @@ // Function to load more node values for display function loadMoreNodes() { if (!queryTree?.nodeValues) return; - - const newCount = Math.min(displayedNodeCount + NODE_VALUES_BATCH_SIZE, queryTree.nodeValues.length); + + const newCount = Math.min( + displayedNodeCount + NODE_VALUES_BATCH_SIZE, + queryTree.nodeValues.length + ); displayedNodeCount = newCount; if (newCount >= queryTree.nodeValues.length) { diff --git a/codyze-console/src/main/webapp/src/lib/components/analysis/index.ts b/codyze-console/src/main/webapp/src/lib/components/analysis/index.ts index 7aa5c441f27..7e25f122834 100644 --- a/codyze-console/src/main/webapp/src/lib/components/analysis/index.ts +++ b/codyze-console/src/main/webapp/src/lib/components/analysis/index.ts @@ -1,5 +1,7 @@ // Analysis components export { default as AnalysisResult } from './AnalysisResult.svelte'; +export { default as CodeViewer } from './CodeViewer.svelte'; +export { default as FileTree } from './FileTree.svelte'; export { default as Finding } from './Finding.svelte'; export { default as FindingOverlay } from './FindingOverlay.svelte'; export { default as FindingsList } from './FindingsList.svelte'; diff --git a/codyze-console/src/main/webapp/src/lib/components/dashboard/CategorySection.svelte b/codyze-console/src/main/webapp/src/lib/components/dashboard/CategorySection.svelte index 57e35e1563e..ea781d6a0ec 100644 --- a/codyze-console/src/main/webapp/src/lib/components/dashboard/CategorySection.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/dashboard/CategorySection.svelte @@ -10,7 +10,7 @@ let { category, initialExpanded = false }: Props = $props(); - let isExpanded = $state(initialExpanded); + let isExpanded = $state(false); // Expand if initialExpanded changes $effect(() => { diff --git a/codyze-console/src/main/webapp/src/lib/components/dashboard/DashboardSection.svelte b/codyze-console/src/main/webapp/src/lib/components/dashboard/DashboardSection.svelte index 80146025d26..7f70ec3c677 100644 --- a/codyze-console/src/main/webapp/src/lib/components/dashboard/DashboardSection.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/dashboard/DashboardSection.svelte @@ -1,11 +1,14 @@
@@ -18,6 +21,6 @@ {/if}
- + {@render children()}
diff --git a/codyze-console/src/main/webapp/src/lib/components/navigation/Sidebar.svelte b/codyze-console/src/main/webapp/src/lib/components/navigation/Sidebar.svelte index a81a7b35729..22d79e9861a 100644 --- a/codyze-console/src/main/webapp/src/lib/components/navigation/Sidebar.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/navigation/Sidebar.svelte @@ -1,5 +1,6 @@ @@ -48,43 +65,65 @@ diff --git a/codyze-console/src/main/webapp/src/lib/components/navigation/TabNavigation.svelte b/codyze-console/src/main/webapp/src/lib/components/navigation/TabNavigation.svelte index 7b0a13c186b..f0defa2e4ad 100644 --- a/codyze-console/src/main/webapp/src/lib/components/navigation/TabNavigation.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/navigation/TabNavigation.svelte @@ -14,27 +14,21 @@ let { tabs, activeTab, onTabChange }: Props = $props(); -
- +
+ {#each tabs as tab} + + {/each}
diff --git a/codyze-console/src/main/webapp/src/lib/components/ui/Button.svelte b/codyze-console/src/main/webapp/src/lib/components/ui/Button.svelte index afd11a9a82e..4d9fa4d3f99 100644 --- a/codyze-console/src/main/webapp/src/lib/components/ui/Button.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/ui/Button.svelte @@ -1,4 +1,6 @@ {#if href && !isDisabled} @@ -56,7 +60,7 @@ > {/if} - + {@render children()} {:else} {/if} diff --git a/codyze-console/src/main/webapp/src/lib/components/ui/Card.svelte b/codyze-console/src/main/webapp/src/lib/components/ui/Card.svelte index 45336b48c58..dfc161924c3 100644 --- a/codyze-console/src/main/webapp/src/lib/components/ui/Card.svelte +++ b/codyze-console/src/main/webapp/src/lib/components/ui/Card.svelte @@ -1,4 +1,6 @@
diff --git a/codyze-console/src/main/webapp/src/lib/services/apiService.ts b/codyze-console/src/main/webapp/src/lib/services/apiService.ts new file mode 100644 index 00000000000..64644f613b8 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/services/apiService.ts @@ -0,0 +1,87 @@ +const DONE_SIGNAL = '[DONE]'; +const ERROR_PREFIX = 'ERROR:'; + +class ApiService { + constructor(private readonly baseUrl: string = '') {} + + async streamPost( + url: string, + body: Record, + onChunk: (chunk: string) => void, + onError?: (error: string) => void, + onComplete?: () => void + ): Promise { + try { + const response = await fetch(`${this.baseUrl}${url}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache' + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + onError?.(`HTTP ${response.status}: ${response.statusText}`); + return; + } + + const reader = response.body?.getReader(); + if (!reader) { + onError?.('Failed to get response reader'); + return; + } + + const decoder = new TextDecoder(); + let buffer = ''; + let eventDataLines: string[] = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + onComplete?.(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const raw of lines) { + const line = raw.replace(/\r$/, ''); + + if (line.startsWith(':')) continue; + + if (line === '') { + if (eventDataLines.length > 0) { + const eventData = eventDataLines.join('\n'); + eventDataLines = []; + + if (eventData === DONE_SIGNAL) { + onComplete?.(); + return; + } + + if (eventData.startsWith(ERROR_PREFIX)) { + onError?.(eventData.slice(ERROR_PREFIX.length).trim()); + continue; + } + + onChunk(eventData); + } + continue; + } + + if (line.startsWith('data:')) { + eventDataLines.push(line.slice(5).trimStart()); + } + } + } + } catch (error) { + onError?.(error instanceof Error ? error.message : String(error)); + } + } +} + +export default ApiService; \ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/services/llmAgent.ts b/codyze-console/src/main/webapp/src/lib/services/llmAgent.ts new file mode 100644 index 00000000000..cda7a4d5624 --- /dev/null +++ b/codyze-console/src/main/webapp/src/lib/services/llmAgent.ts @@ -0,0 +1,24 @@ +import ApiService from './apiService'; +import type { LLMMessage } from '$lib/types'; + +export interface StreamingCallbacks { + onChunk: (chunk: string) => void; + onError?: (error: string) => void; + onComplete?: () => void; +} + +class LLMAgent { + private apiService = new ApiService(); + + async chat(messages: LLMMessage[], callbacks: StreamingCallbacks): Promise { + await this.apiService.streamPost( + '/api/chat', + { messages }, + callbacks.onChunk, + callbacks.onError, + callbacks.onComplete + ); + } +} + +export const llmAgent = new LLMAgent(); \ No newline at end of file diff --git a/codyze-console/src/main/webapp/src/lib/types.ts b/codyze-console/src/main/webapp/src/lib/types.ts index 24e8dc83fc3..9eb5f4b5185 100644 --- a/codyze-console/src/main/webapp/src/lib/types.ts +++ b/codyze-console/src/main/webapp/src/lib/types.ts @@ -69,6 +69,65 @@ export interface EdgeJSON { end: string; } +// MCP capabilities +export interface McpToolInfo { + name: string; + description?: string; + inputSchema?: { + properties?: Record; + required?: string[]; + }; +} + +export interface McpPromptArgument { + name: string; + description?: string; + required?: boolean; +} + +export interface McpPromptInfo { + name: string; + description?: string; + arguments?: McpPromptArgument[]; +} + +export interface McpResourceInfo { + uri: string; + name?: string; + description?: string; + mimeType?: string; +} + +export interface McpCapabilities { + serverName: string; + serverVersion: string; + tools: McpToolInfo[]; + prompts: McpPromptInfo[]; + resources: McpResourceInfo[]; +} + +// AI Agent / Chat interfaces +export interface LLMMessage { + role: 'user' | 'assistant' | 'system'; + content: string; +} + +export interface ToolResult { + toolName?: string; + content: any; + isError?: boolean; +} + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + contentType?: 'text' | 'tool-result'; + toolResult?: ToolResult; + reasoning?: string; + timestamp: Date; +} + export interface ConceptInfo { conceptName: string; constructorInfo: ConstructorInfo[]; diff --git a/codyze-console/src/main/webapp/src/routes/+layout.svelte b/codyze-console/src/main/webapp/src/routes/+layout.svelte index e91effcabb7..f3654f75cc9 100644 --- a/codyze-console/src/main/webapp/src/routes/+layout.svelte +++ b/codyze-console/src/main/webapp/src/routes/+layout.svelte @@ -5,6 +5,9 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import type { AnalysisProjectJSON } from '$lib/types'; + import type { Snippet } from 'svelte'; + + let { children }: { children: Snippet } = $props(); let project = $state(null); let loading = $state(true); @@ -39,15 +42,15 @@
-
-
+
+
{#if error}
{error}
{/if} - + {@render children()}
diff --git a/codyze-console/src/main/webapp/src/routes/chat/+page.svelte b/codyze-console/src/main/webapp/src/routes/chat/+page.svelte new file mode 100644 index 00000000000..9621acbd385 --- /dev/null +++ b/codyze-console/src/main/webapp/src/routes/chat/+page.svelte @@ -0,0 +1,248 @@ + + + + +
+ {#if mcpCapabilities === null} + + {:else if showWelcome} +
+ (showMcpModal = true)} + onPromptSelect={handlePromptSelect} + /> +
+ {:else} + 0} + {analysisResult} + {mcpCapabilities} + onSendMessage={sendMessage} + onReset={resetChat} + onMessageChange={(message) => (currentMessage = message)} + onPromptSelect={handlePromptSelect} + onOpenMcpModal={() => (showMcpModal = true)} + /> + {/if} +
+ +{#if showMcpModal && mcpCapabilities} + (showMcpModal = false)} + /> +{/if} diff --git a/codyze-console/src/main/webapp/src/routes/chat/+page.ts b/codyze-console/src/main/webapp/src/routes/chat/+page.ts new file mode 100644 index 00000000000..35d5a6e4388 --- /dev/null +++ b/codyze-console/src/main/webapp/src/routes/chat/+page.ts @@ -0,0 +1,29 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; +import type { AnalysisResultJSON, McpCapabilities } from '$lib/types'; + +export const load: PageLoad = async ({ fetch }) => { + try { + const featuresRes = await fetch('/api/features'); + if (featuresRes.ok) { + const features = await featuresRes.json(); + if (!features.mcpEnabled) { + throw redirect(302, '/dashboard'); + } + } + + const [resultRes, capsRes] = await Promise.all([ + fetch('/api/result'), + fetch('/api/chat/mcp/capabilities') + ]); + + const result: AnalysisResultJSON | null = resultRes.ok ? await resultRes.json().catch(() => null) : null; + const mcpCapabilities: McpCapabilities | null = capsRes.ok ? await capsRes.json().catch(() => null) : null; + + return { result, mcpCapabilities }; + } catch (error) { + if (error && typeof error === 'object' && 'status' in error) throw error; + console.error('Error loading chat page:', error); + return { result: null, mcpCapabilities: null }; + } +}; diff --git a/codyze-console/src/main/webapp/src/routes/components/[componentName]/+layout.svelte b/codyze-console/src/main/webapp/src/routes/components/[componentName]/+layout.svelte index 653728489ff..3add6a2e693 100644 --- a/codyze-console/src/main/webapp/src/routes/components/[componentName]/+layout.svelte +++ b/codyze-console/src/main/webapp/src/routes/components/[componentName]/+layout.svelte @@ -1,7 +1,7 @@ - -
-
-
{data.translationUnit.name}
+ + {#snippet headerActions()} -
- -
-
- - - -
- - {#if finding && line} - - {/if} - - -
-
- - -
-
- -
- -
- console.log('Node clicked:', node)} - /> -
-
+ {/snippet} + \ No newline at end of file diff --git a/codyze-console/src/test/kotlin/de/fraunhofer/aisec/codyze/console/ApplicationTest.kt b/codyze-console/src/test/kotlin/de/fraunhofer/aisec/codyze/console/ApplicationTest.kt index 1bcdc39489d..5efccee850d 100644 --- a/codyze-console/src/test/kotlin/de/fraunhofer/aisec/codyze/console/ApplicationTest.kt +++ b/codyze-console/src/test/kotlin/de/fraunhofer/aisec/codyze/console/ApplicationTest.kt @@ -40,6 +40,7 @@ import de.fraunhofer.aisec.cpg.graph.declarations.TranslationUnit import de.fraunhofer.aisec.cpg.graph.statements.expressions.Block import de.fraunhofer.aisec.cpg.graph.statements.expressions.Call import de.fraunhofer.aisec.cpg.graph.statements.expressions.Reference +import de.fraunhofer.aisec.cpg.serialization.NodeJSON import io.github.detekt.sarif4k.* import io.ktor.client.call.* import io.ktor.client.plugins.contentnegotiation.* diff --git a/codyze-core/src/integrationTest/resources/example/src/module1/main.py b/codyze-core/src/integrationTest/resources/example/src/module1/main.py index 94284738456..44455dcca18 100644 --- a/codyze-core/src/integrationTest/resources/example/src/module1/main.py +++ b/codyze-core/src/integrationTest/resources/example/src/module1/main.py @@ -3,4 +3,4 @@ key = get_secret_from_server() err = encrypt("Hello World", key, cipher = "AES-256") -special_func() +special_func() \ No newline at end of file diff --git a/codyze-core/src/integrationTest/resources/example/src/module2/pkg/mod.py b/codyze-core/src/integrationTest/resources/example/src/module2/pkg/mod.py index 9332a2735b6..6584985db84 100644 --- a/codyze-core/src/integrationTest/resources/example/src/module2/pkg/mod.py +++ b/codyze-core/src/integrationTest/resources/example/src/module2/pkg/mod.py @@ -1,2 +1,2 @@ def foo(): - pass + pass \ No newline at end of file diff --git a/codyze-core/src/integrationTest/resources/example/src/third-party/mylib/library/lib.py b/codyze-core/src/integrationTest/resources/example/src/third-party/mylib/library/lib.py index 4680aa75ff6..adc98d02e07 100644 --- a/codyze-core/src/integrationTest/resources/example/src/third-party/mylib/library/lib.py +++ b/codyze-core/src/integrationTest/resources/example/src/third-party/mylib/library/lib.py @@ -1,2 +1,2 @@ def special_func(): - pass + pass \ No newline at end of file diff --git a/cpg-mcp/build.gradle.kts b/cpg-mcp/build.gradle.kts index 427a7fd40be..a5401a6d286 100644 --- a/cpg-mcp/build.gradle.kts +++ b/cpg-mcp/build.gradle.kts @@ -67,19 +67,22 @@ dependencies { implementation(libs.mcp) implementation(libs.ktor.server.cio) implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.server.cors) + implementation(libs.ktor.server.content.negotiation) // Test dependencies testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") // Command line interface support - api(libs.picocli) - annotationProcessor(libs.picocli.codegen) + implementation(libs.clikt) integrationTestImplementation(libs.kotlin.reflect) implementation(libs.reflections) integrationTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") integrationTestImplementation(libs.mcp) + integrationTestImplementation(libs.mcp.client) integrationTestImplementation(libs.ktor.serialization.kotlinx.json) + integrationTestImplementation(project(":cpg-serialization")) // We depend on the Python frontend for the integration tests, but the frontend is only // available if enabled. // If it's not available, the integration tests fail (which is ok). But if we would directly @@ -89,4 +92,5 @@ dependencies { implementation(project(":cpg-concepts")) implementation(project(":cpg-analysis")) + implementation(project(":cpg-serialization")) } diff --git a/cpg-mcp/src/integrationTest/kotlin/mcp/CpgLlmAnalyzeToolTest.kt b/cpg-mcp/src/integrationTest/kotlin/mcp/CpgLlmAnalyzeToolTest.kt deleted file mode 100644 index ef7f7c89a05..00000000000 --- a/cpg-mcp/src/integrationTest/kotlin/mcp/CpgLlmAnalyzeToolTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * $$$$$$\ $$$$$$$\ $$$$$$\ - * $$ __$$\ $$ __$$\ $$ __$$\ - * $$ / \__|$$ | $$ |$$ / \__| - * $$ | $$$$$$$ |$$ |$$$$\ - * $$ | $$ ____/ $$ |\_$$ | - * $$ | $$\ $$ | $$ | $$ | - * \$$$$$ |$$ | \$$$$$ | - * \______/ \__| \______/ - * - */ -package de.fraunhofer.aisec.cpg.mcp - -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgLlmAnalyzeTool -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.globalAnalysisResult -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.runCpgAnalyze -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgAnalyzePayload -import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest -import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequestParams -import io.modelcontextprotocol.kotlin.sdk.types.Implementation -import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities -import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import org.junit.jupiter.api.BeforeEach - -class CpgLlmAnalyzeToolTest { - - private lateinit var server: Server - - @BeforeEach - fun initializeServer() { - val payload = - CpgAnalyzePayload(content = "def hello():\n print('Hello World')", extension = "py") - val analysisResult = runCpgAnalyze(payload, runPasses = true, cleanup = true) - assertNotNull(globalAnalysisResult, "Result should be set after tool execution") - - assertEquals(2, analysisResult.functions) - assertEquals(1, analysisResult.callExpressions) - assertNotNull(analysisResult.nodes) - val info = Implementation(name = "test-cpg-server", version = "1.0.0") - val options = - ServerOptions( - capabilities = - ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)) - ) - server = Server(info, options) - } - - @Test - fun cpgLlmAnalyzeNoPayload() = runTest { - val info = Implementation(name = "test-cpg-server", version = "1.0.0") - val options = - ServerOptions( - capabilities = - ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)) - ) - server = Server(info, options) - - server.addCpgLlmAnalyzeTool() - - val inputSchema = buildJsonObject {} - - val request = - CallToolRequest( - CallToolRequestParams(name = "cpg_llm_analyze", arguments = inputSchema) - ) - - val tool = server.tools["cpg_llm_analyze"] ?: error("Tool not registered") - val result = tool.handler(request) - - val resultContent = result.content.firstOrNull() - assertIs(resultContent) - val resultText = resultContent.text - assertNotNull(resultText, "Result content should not be null") - assertFalse( - "## Additional Context" in resultText, - "Result content should not contain the section 'Additional Context'", - ) - } - - @Test - fun cpgLlmAnalyzeWithPayload() = runTest { - val info = Implementation(name = "test-cpg-server", version = "1.0.0") - val options = - ServerOptions( - capabilities = - ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)) - ) - server = Server(info, options) - - server.addCpgLlmAnalyzeTool() - - val inputSchema = buildJsonObject { - put("description", "We have some additional context here.") - } - - val request = - CallToolRequest( - CallToolRequestParams(name = "cpg_llm_analyze", arguments = inputSchema) - ) - - val tool = server.tools["cpg_llm_analyze"] ?: error("Tool not registered") - val result = tool.handler(request) - - val resultContent = result.content.firstOrNull() - assertIs(resultContent) - val resultText = resultContent.text - assertNotNull(resultText, "Result content should not be null") - assertTrue( - "## Additional Context" in resultText, - "Result content should not contain the section 'Additional Context'", - ) - } -} diff --git a/cpg-mcp/src/integrationTest/kotlin/mcp/prompts/CpgSuggestConceptsPromptTest.kt b/cpg-mcp/src/integrationTest/kotlin/mcp/prompts/CpgSuggestConceptsPromptTest.kt new file mode 100644 index 00000000000..1d0516a1132 --- /dev/null +++ b/cpg-mcp/src/integrationTest/kotlin/mcp/prompts/CpgSuggestConceptsPromptTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.cpg.mcp.prompts + +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addSuggestConceptsPrompt +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequest +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class CpgSuggestConceptsPromptTest { + private fun createServer(): Server { + val server = + Server( + Implementation(name = "test-cpg-server", version = "1.0.0"), + ServerOptions( + ServerCapabilities(prompts = ServerCapabilities.Prompts(listChanged = true)) + ), + ) + server.addSuggestConceptsPrompt() + return server + } + + @Test + fun suggestConceptsNoDescription() = runTest { + val server = createServer() + val result = + (server.prompts["suggest_concepts"] ?: error("Prompt not registered")).messageProvider( + GetPromptRequest(GetPromptRequestParams(name = "suggest_concepts")) + ) + + assertNotNull(result) + val text = (result.messages.firstOrNull()?.content as? TextContent)?.text + assertNotNull(text, "Prompt message should have text content") + assertFalse( + "## Additional Context" in text, + "Result should not contain 'Additional Context' when no description is given", + ) + } + + @Test + fun suggestConceptsWithDescription() = runTest { + val server = createServer() + val result = + (server.prompts["suggest_concepts"] ?: error("Prompt not registered")).messageProvider( + GetPromptRequest( + GetPromptRequestParams( + name = "suggest_concepts", + arguments = mapOf("description" to "We have some additional context here."), + ) + ) + ) + + assertNotNull(result) + val text = (result.messages.firstOrNull()?.content as? TextContent)?.text + assertNotNull(text, "Prompt message should have text content") + assertTrue( + "## Additional Context" in text, + "Result should contain 'Additional Context' when description is provided", + ) + } +} diff --git a/cpg-mcp/src/integrationTest/kotlin/mcp/ApplyConceptsTest.kt b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/ApplyConceptsTest.kt similarity index 69% rename from cpg-mcp/src/integrationTest/kotlin/mcp/ApplyConceptsTest.kt rename to cpg-mcp/src/integrationTest/kotlin/mcp/tools/ApplyConceptsTest.kt index b72f676415f..fd0be3cb5c8 100644 --- a/cpg-mcp/src/integrationTest/kotlin/mcp/ApplyConceptsTest.kt +++ b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/ApplyConceptsTest.kt @@ -23,17 +23,14 @@ * \______/ \__| \______/ * */ -package de.fraunhofer.aisec.cpg.mcp +package de.fraunhofer.aisec.cpg.mcp.tools -import de.fraunhofer.aisec.cpg.graph.* import de.fraunhofer.aisec.cpg.graph.literals import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgApplyConceptsTool import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.globalAnalysisResult import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listConceptsAndOperations import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.runCpgAnalyze import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgAnalyzePayload -import io.ktor.client.request.invoke -import io.ktor.http.invoke import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest @@ -64,10 +61,9 @@ class ApplyConceptsTest { ) val analysisResult = runCpgAnalyze(payload, runPasses = true, cleanup = true) assertNotNull(globalAnalysisResult, "Result should be set after tool execution") - assertEquals(2, analysisResult.functions) assertEquals(1, analysisResult.callExpressions) - assertNotNull(analysisResult.nodes) + assertNotNull(analysisResult.functionSummaries) val info = Implementation(name = "test-cpg-server", version = "1.0.0") val options = ServerOptions( @@ -85,45 +81,51 @@ class ApplyConceptsTest { val secretInitializer = globalAnalysisResult?.literals?.singleOrNull { it.value == "0000" } assertNotNull(secretInitializer) - val applyRequest = - CallToolRequest( - CallToolRequestParams( - name = "cpg_apply_concepts", - arguments = - buildJsonObject { - putJsonArray("assignments") { - add( - buildJsonObject { - put("nodeId", secretInitializer.id.toString()) - put( - "overlay", - "de.fraunhofer.aisec.cpg.graph.concepts.crypto.encryption.Secret", - ) - put("overlayType", "Concept") - } - ) - } - }, + val applyResult = + applyTool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_apply_concepts", + arguments = + buildJsonObject { + putJsonArray("assignments") { + add( + buildJsonObject { + put("nodeId", secretInitializer.id.toString()) + put( + "overlay", + "de.fraunhofer.aisec.cpg.graph.concepts.crypto.encryption.Secret", + ) + put("overlayType", "Concept") + } + ) + } + }, + ) ) ) - val applyResult = applyTool.handler(applyRequest) assertNotNull(applyResult) - assertTrue(applyResult.content.isNotEmpty(), "We did apply a concepts") + assertTrue(applyResult.content.isNotEmpty(), "We did apply a concept") assertTrue( "Applied 1 concept(s):" in (applyResult.content.singleOrNull() as? TextContent)?.text.orEmpty() ) - val tool = server.tools["cpg_list_concepts_and_operations"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_concepts_and_operations", - arguments = buildJsonObject {}, + val listTool = + server.tools["cpg_list_concepts_and_operations"] ?: error("Tool not registered") + val listResult = + listTool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_concepts_and_operations", + arguments = buildJsonObject {}, + ) ) ) - val result = tool.handler(request) - assertNotNull(result) - assertTrue(result.content.isNotEmpty(), "We did apply a, so it should not be empty") + assertNotNull(listResult) + assertTrue( + listResult.content.isNotEmpty(), + "We did apply a concept, so it should not be empty", + ) } } diff --git a/cpg-mcp/src/integrationTest/kotlin/mcp/CpgAnalyzeToolTest.kt b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/CpgAnalyzeToolTest.kt similarity index 83% rename from cpg-mcp/src/integrationTest/kotlin/mcp/CpgAnalyzeToolTest.kt rename to cpg-mcp/src/integrationTest/kotlin/mcp/tools/CpgAnalyzeToolTest.kt index 34a58743fc3..82cb5ca0863 100644 --- a/cpg-mcp/src/integrationTest/kotlin/mcp/CpgAnalyzeToolTest.kt +++ b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/CpgAnalyzeToolTest.kt @@ -23,7 +23,7 @@ * \______/ \__| \______/ * */ -package de.fraunhofer.aisec.cpg.mcp +package de.fraunhofer.aisec.cpg.mcp.tools import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgAnalyzeTool import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.globalAnalysisResult @@ -47,7 +47,6 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put class CpgAnalyzeToolTest { - private lateinit var server: Server @Test @@ -59,19 +58,21 @@ class CpgAnalyzeToolTest { ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)) ) server = Server(info, options) - server.addCpgAnalyzeTool() - val inputSchema = buildJsonObject { - put("content", "def hello():\n print('Hello World')") - put("extension", "py") - } - - val request = - CallToolRequest(CallToolRequestParams(name = "cpg_analyze", arguments = inputSchema)) - - val tool = server.tools["cpg_analyze"] ?: error("Tool not registered") - val result = tool.handler(request) + val result = + (server.tools["cpg_analyze"] ?: error("Tool not registered")).handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_analyze", + arguments = + buildJsonObject { + put("content", "def hello():\n print('Hello World')") + put("extension", "py") + }, + ) + ) + ) assertNotNull(globalAnalysisResult, "Result should be set after tool execution") @@ -84,7 +85,7 @@ class CpgAnalyzeToolTest { assertEquals(2, analysisResult.functions) assertEquals(1, analysisResult.callExpressions) - assertNotNull(analysisResult.nodes) + assertNotNull(analysisResult.functionSummaries) } @Test @@ -96,6 +97,6 @@ class CpgAnalyzeToolTest { assertEquals(2, analysisResult.functions) assertEquals(1, analysisResult.callExpressions) - assertNotNull(analysisResult.nodes) + assertNotNull(analysisResult.functionSummaries) } } diff --git a/cpg-mcp/src/integrationTest/kotlin/mcp/ListCommandsTest.kt b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/ListCommandsTest.kt similarity index 66% rename from cpg-mcp/src/integrationTest/kotlin/mcp/ListCommandsTest.kt rename to cpg-mcp/src/integrationTest/kotlin/mcp/tools/ListCommandsTest.kt index 862ca5735cf..1d6e21575b6 100644 --- a/cpg-mcp/src/integrationTest/kotlin/mcp/ListCommandsTest.kt +++ b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/ListCommandsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * Copyright (c) 2026, Fraunhofer AISEC. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ * \______/ \__| \______/ * */ -package de.fraunhofer.aisec.cpg.mcp +package de.fraunhofer.aisec.cpg.mcp.tools import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.getAllArgs import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.getArgByIndexOrName @@ -36,11 +36,11 @@ import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listConceptsAndOperations import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listFunctions import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listRecords import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.runCpgAnalyze -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CallInfo +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CallSummary import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgAnalyzePayload -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.FunctionInfo -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.NodeInfo -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.RecordInfo +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.FunctionSummary +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.RecordSummary +import de.fraunhofer.aisec.cpg.serialization.NodeJSON import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest @@ -74,10 +74,9 @@ class ListCommandsTest { ) val analysisResult = runCpgAnalyze(payload, runPasses = true, cleanup = true) assertNotNull(globalAnalysisResult, "Result should be set after tool execution") - assertEquals(2, analysisResult.functions) assertEquals(1, analysisResult.callExpressions) - assertNotNull(analysisResult.nodes) + assertNotNull(analysisResult.functionSummaries) val info = Implementation(name = "test-cpg-server", version = "1.0.0") val options = ServerOptions( @@ -90,27 +89,30 @@ class ListCommandsTest { @Test fun listFunctionsTest() = runTest { server.listFunctions() - val tool = server.tools["cpg_list_functions"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams(name = "cpg_list_functions", arguments = buildJsonObject {}) + val result = + tool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_functions", + arguments = buildJsonObject {}, + ) + ) ) - val result = tool.handler(request) assertNotNull(result, "Result should not be null") assertEquals(2, result.content.size, "Should return two function declarations") val functionNames = result.content.map { assertIs(it) - Json.decodeFromString(it.text).name + Json.decodeFromString(it.text).name } assertNotNull( functionNames.singleOrNull { it == "print" }, "There is exactly one function declaration with name print", ) assertNotNull( - functionNames.singleOrNull { it.endsWith(".hello") }, + functionNames.singleOrNull { it.endsWith("hello") }, "There is exactly one function declaration with local name hello", ) } @@ -119,18 +121,19 @@ class ListCommandsTest { fun listRecordsTest() = runTest { server.listRecords() val tool = server.tools["cpg_list_records"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams(name = "cpg_list_records", arguments = buildJsonObject {}) + val result = + tool.handler( + CallToolRequest( + CallToolRequestParams(name = "cpg_list_records", arguments = buildJsonObject {}) + ) ) - val result = tool.handler(request) assertNotNull(result) assertTrue( result.content.isNotEmpty(), "There is a record declaration \"Foo\" in the test code", ) assertDoesNotThrow { - Json.decodeFromString( + Json.decodeFromString( (result.content.singleOrNull() as? TextContent)?.text.orEmpty() ) } @@ -140,11 +143,12 @@ class ListCommandsTest { fun listCallsTest() = runTest { server.listCalls() val tool = server.tools["cpg_list_calls"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams(name = "cpg_list_calls", arguments = buildJsonObject {}) + val result = + tool.handler( + CallToolRequest( + CallToolRequestParams(name = "cpg_list_calls", arguments = buildJsonObject {}) + ) ) - val result = tool.handler(request) assertNotNull(result) assertEquals(1, result.content.size, "Should return one call expression") } @@ -153,14 +157,15 @@ class ListCommandsTest { fun listCallsToTest() = runTest { server.listCallsTo() val tool = server.tools["cpg_list_calls_to"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_calls_to", - arguments = buildJsonObject { put("name", "print") }, + val result = + tool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_calls_to", + arguments = buildJsonObject { put("name", "print") }, + ) ) ) - val result = tool.handler(request) assertNotNull(result) assertTrue(result.content.isNotEmpty(), "Should return calls to 'print'") } @@ -169,44 +174,47 @@ class ListCommandsTest { fun getAllArgsTest() = runTest { server.listCalls() val callsTool = server.tools["cpg_list_calls"] ?: error("Tool not registered") - val callsRequest = - CallToolRequest( - CallToolRequestParams(name = "cpg_list_calls", arguments = buildJsonObject {}) + val callsResult = + callsTool.handler( + CallToolRequest( + CallToolRequestParams(name = "cpg_list_calls", arguments = buildJsonObject {}) + ) ) - val callsResult = callsTool.handler(callsRequest) val callId = - Json.decodeFromString((callsResult.content.first() as TextContent).text) - .nodeId + Json.decodeFromString((callsResult.content.first() as TextContent).text).id server.getAllArgs() val argsTool = server.tools["cpg_list_call_args"] ?: error("Tool not registered") - val argsRequest = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_call_args", - arguments = buildJsonObject { put("id", callId) }, + val argsResult = + argsTool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_call_args", + arguments = buildJsonObject { put("id", callId) }, + ) ) ) - val argsResult = argsTool.handler(argsRequest) assertNotNull(argsResult) assertTrue(argsResult.content.isNotEmpty(), "Should return arguments for the call") assertDoesNotThrow { - Json.decodeFromString( + Json.decodeFromString( (argsResult.content.singleOrNull() as? TextContent)?.text.orEmpty() ) } - val wrongArgsRequest = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_call_args", - arguments = buildJsonObject { put("nodeId", callId) }, + + val wrongArgsResult = + argsTool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_call_args", + arguments = buildJsonObject { put("nodeId", callId) }, + ) ) ) - val wrongArgsResult = argsTool.handler(wrongArgsRequest) assertNotNull(wrongArgsResult) assertTrue(wrongArgsResult.content.isNotEmpty(), "Should return arguments for the call") assertThrows { - Json.decodeFromString((wrongArgsResult.content.first() as TextContent).text) + Json.decodeFromString((wrongArgsResult.content.first() as TextContent).text) } } @@ -214,51 +222,52 @@ class ListCommandsTest { fun getArgByIndexOrNameTest() = runTest { server.listCalls() val callsTool = server.tools["cpg_list_calls"] ?: error("Tool not registered") - val callsRequest = - CallToolRequest( - CallToolRequestParams(name = "cpg_list_calls", arguments = buildJsonObject {}) + val callsResult = + callsTool.handler( + CallToolRequest( + CallToolRequestParams(name = "cpg_list_calls", arguments = buildJsonObject {}) + ) ) - val callsResult = callsTool.handler(callsRequest) val callId = - Json.decodeFromString((callsResult.content.first() as TextContent).text) - .nodeId + Json.decodeFromString((callsResult.content.first() as TextContent).text).id server.getArgByIndexOrName() val argTool = server.tools["cpg_list_call_arg_by_name_or_index"] ?: error("Tool not registered") - - val argRequestByIndex = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_call_arg_by_name_or_index", - arguments = - buildJsonObject { - put("nodeId", callId) - put("index", 0) - }, + val argResultByIndex = + argTool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_call_arg_by_name_or_index", + arguments = + buildJsonObject { + put("nodeId", callId) + put("index", 0) + }, + ) ) ) - val argResultByIndex = argTool.handler(argRequestByIndex) assertNotNull(argResultByIndex) assertTrue(argResultByIndex.content.isNotEmpty(), "Should return the argument at index 0") assertDoesNotThrow { - Json.decodeFromString( + Json.decodeFromString( (argResultByIndex.content.singleOrNull() as? TextContent)?.text.orEmpty() ) } - val argRequestByName = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_call_arg_by_name_or_index", - arguments = buildJsonObject { put("id", callId) }, + val argResultByName = + argTool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_call_arg_by_name_or_index", + arguments = buildJsonObject { put("id", callId) }, + ) ) ) - val argResultByName = argTool.handler(argRequestByName) assertNotNull(argResultByName) assertTrue(argResultByName.content.isNotEmpty(), "Should return the error message") assertThrows { - Json.decodeFromString( + Json.decodeFromString( (argResultByName.content.singleOrNull() as? TextContent)?.text.orEmpty() ) } @@ -268,14 +277,15 @@ class ListCommandsTest { fun listAvailableConceptsTest() = runTest { server.listAvailableConcepts() val tool = server.tools["cpg_list_available_concepts"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_available_concepts", - arguments = buildJsonObject {}, + val result = + tool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_available_concepts", + arguments = buildJsonObject {}, + ) ) ) - val result = tool.handler(request) assertNotNull(result) assertTrue(result.content.isNotEmpty(), "Should return available concepts") } @@ -284,14 +294,15 @@ class ListCommandsTest { fun listAvailableOperationsTest() = runTest { server.listAvailableOperations() val tool = server.tools["cpg_list_available_operations"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_available_operations", - arguments = buildJsonObject {}, + val result = + tool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_available_operations", + arguments = buildJsonObject {}, + ) ) ) - val result = tool.handler(request) assertNotNull(result) assertTrue(result.content.isNotEmpty(), "Should return available operations") } @@ -300,14 +311,15 @@ class ListCommandsTest { fun listAvailableConceptsAndOperationsTest() = runTest { server.listConceptsAndOperations() val tool = server.tools["cpg_list_concepts_and_operations"] ?: error("Tool not registered") - val request = - CallToolRequest( - CallToolRequestParams( - name = "cpg_list_concepts_and_operations", - arguments = buildJsonObject {}, + val result = + tool.handler( + CallToolRequest( + CallToolRequestParams( + name = "cpg_list_concepts_and_operations", + arguments = buildJsonObject {}, + ) ) ) - val result = tool.handler(request) assertNotNull(result) assertTrue( result.content.isEmpty(), diff --git a/cpg-mcp/src/integrationTest/kotlin/mcp/RunPassTest.kt b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/RunPassTest.kt similarity index 99% rename from cpg-mcp/src/integrationTest/kotlin/mcp/RunPassTest.kt rename to cpg-mcp/src/integrationTest/kotlin/mcp/tools/RunPassTest.kt index 4e08533ded1..47b76d6f92b 100644 --- a/cpg-mcp/src/integrationTest/kotlin/mcp/RunPassTest.kt +++ b/cpg-mcp/src/integrationTest/kotlin/mcp/tools/RunPassTest.kt @@ -170,8 +170,10 @@ class RunPassTest { val text = (result.content.firstOrNull() as? TextContent)?.text assertNotNull(text) val parsed = Json.decodeFromString(text) - assertNotNull(parsed.nodes) - assertTrue(parsed.nodes.any { it.name.contains("hello") || it.name.contains("print") }) + assertNotNull(parsed.functionSummaries) + assertTrue( + parsed.functionSummaries.any { it.name.contains("hello") || it.name.contains("print") } + ) } @Test diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/Application.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/Application.kt index fa26acc7fe6..fb9c2f78e37 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/Application.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/Application.kt @@ -25,9 +25,16 @@ */ package de.fraunhofer.aisec.cpg.mcp +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.int import de.fraunhofer.aisec.cpg.mcp.mcpserver.configureServer +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.install import io.ktor.server.cio.* import io.ktor.server.engine.* +import io.ktor.server.plugins.contentnegotiation.* import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport import io.modelcontextprotocol.kotlin.sdk.server.mcp @@ -37,63 +44,32 @@ import kotlinx.coroutines.runBlocking import kotlinx.io.asSink import kotlinx.io.asSource import kotlinx.io.buffered -import picocli.CommandLine -@CommandLine.Command(name = "cpg-mcp") -class Application : Runnable { - @CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1") - var transport: TransportOptions? = null +class Application : CliktCommand(name = "cpg-mcp") { + private val ssePort by + option("--sse", help = "Provide the port to run SSE (Server Sent Events).").int() - class TransportOptions { - @CommandLine.Option( - names = ["--stdio"], - description = ["Run the MCP server using stdio (default option)."], - ) - var stdio: Boolean = false - - @CommandLine.Option( - names = ["--sse"], - description = ["Provide the port to run SSE (Server Sent Events)."], - ) - var ssePort: Int? = null - - @CommandLine.Option(names = ["--http"], description = ["Provide the port to run HTTP."]) - var httpPort: Int? = null - } - - @CommandLine.Option( - names = ["--host"], - description = - [ - "Configure for which hosts the MCP server should be accessible. We expect a valid IP address. Default is 0.0.0.0" - ], - ) - var host: String? = null + private val httpPort by + option("--http", help = "Provide the port to run streamable HTTP.").int() override fun run() { - val host = host ?: "0.0.0.0" - val httpPort = transport?.httpPort - val ssePort = transport?.ssePort - if (httpPort != null) { - runHttpMcpServerUsingKtorPlugin( - port = httpPort, - host = host, - server = configureServer(), - ) - } else if (ssePort != null) { - runSseMcpServerUsingKtorPlugin(port = ssePort, host = host, server = configureServer()) - } else if (transport?.stdio == true) { - runMcpServerUsingStdio() + val http = httpPort + val sse = ssePort + if (http != null) { + println("Starting MCP server in streamable HTTP mode on port $http...") + runHttpMcpServerUsingKtorPlugin(port = http, server = configureServer(), wait = true) + } else if (sse != null) { + println("Starting MCP server in SSE mode on port $sse...") + runSseMcpServerUsingKtorPlugin(sse, configureServer(), wait = true) } else { - // this is the default / fallback case if no transport option is provided, we run the - // stdio server + println("Starting MCP server in stdio mode...") runMcpServerUsingStdio() } } } fun main(args: Array) { - CommandLine(Application()).execute(*args) + Application().main(args) } fun runMcpServerUsingStdio() { @@ -103,7 +79,7 @@ fun runMcpServerUsingStdio() { runBlocking { val job = Job() server.onClose { job.complete() } - server.connect(transport) + server.createSession(transport) job.join() } } @@ -114,25 +90,38 @@ fun runMcpServerUsingStdio() { * The url can be accessed in the MCP inspector at [http://localhost:$port] * * @param port The port number on which the SSE MCP server will listen for client connections. + * @param wait If true the thread is blocked until the server stops. This flag is needed when the + * server runs in the background alongside another server (e.g. in codyze-console). * @param host The host/IP address on which the server will bind. * @param server The MCP server instance that will handle incoming requests and provide responses to * clients. */ -fun runSseMcpServerUsingKtorPlugin(port: Int, host: String, server: Server) = runBlocking { - embeddedServer(CIO, host = host, port = port) { mcp { server } }.start(wait = true) +fun runSseMcpServerUsingKtorPlugin( + port: Int, + server: Server, + wait: Boolean = false, + host: String = "0.0.0.0", +) { + embeddedServer(CIO, host = host, port = port) { mcp { server } }.start(wait = wait) } /** * Starts a streamable HTTP MCP server using the Ktor framework and the specified port. * - * @param port The port number on which the SSE MCP server will listen for client connections. + * @param port The port number on which the HTTP MCP server will listen for client connections. * @param host The host/IP address on which the server will bind. * @param server The MCP server instance that will handle incoming requests and provide responses to * clients. */ -fun runHttpMcpServerUsingKtorPlugin(port: Int, host: String, server: Server) { - runBlocking { - embeddedServer(factory = CIO, host = host, port = port) { mcpStreamableHttp { server } } - .start(wait = true) - } +fun runHttpMcpServerUsingKtorPlugin( + port: Int, + host: String = "0.0.0.0", + server: Server, + wait: Boolean = false, +) { + embeddedServer(factory = CIO, host = host, port = port) { + install(ContentNegotiation) { json() } + mcpStreamableHttp { server } + } + .start(wait = wait) } diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/McpServer.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/McpServer.kt index a964b313f9d..acb40940bc4 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/McpServer.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/McpServer.kt @@ -25,22 +25,7 @@ */ package de.fraunhofer.aisec.cpg.mcp.mcpserver -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgAnalyzeTool -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgApplyConceptsTool -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgDataflowTool -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgLlmAnalyzeTool -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addCpgTranslate -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addListPasses -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.addRunPass -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.getAllArgs -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.getArgByIndexOrName -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listAvailableConcepts -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listAvailableOperations -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listCalls -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listCallsTo -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listConceptsAndOperations -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listFunctions -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.listRecords +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.* import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.types.Implementation @@ -48,11 +33,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities fun configureServer( configure: Server.() -> Server = { + // TOOLS this.addCpgTranslate() this.addListPasses() this.addRunPass() this.addCpgAnalyzeTool() - this.addCpgLlmAnalyzeTool() this.addCpgApplyConceptsTool() this.addCpgDataflowTool() this.listFunctions() @@ -64,6 +49,10 @@ fun configureServer( this.getAllArgs() this.getArgByIndexOrName() this.listConceptsAndOperations() + this.getNode() + this.addDfgBackwardTool() + // PROMPTS + this.addSuggestConceptsPrompt() this } ): Server { @@ -82,6 +71,9 @@ fun configureServer( return Server(info, options).configure() } +/** Default configureServer constructor call to use via reflection (e.g. from codyze-console). */ +fun configureDefaultServer(): Server = configureServer() + const val cpgDescription = """ This server provides tools to analyze the Fraunhofer AISEC CPG (Code Property Graph) and diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgAnalyzeTool.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgAnalyzeTool.kt index 904689f05f6..bdad9b9cb56 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgAnalyzeTool.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgAnalyzeTool.kt @@ -69,9 +69,9 @@ import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgAnalyzePayload import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgRunPassPayload import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.PassInfo import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.addTool -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toNodeInfo import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toObject import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toSchema +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toSummary import de.fraunhofer.aisec.cpg.mcp.setupTranslationConfiguration import de.fraunhofer.aisec.cpg.passes.BasicBlockCollectorPass import de.fraunhofer.aisec.cpg.passes.ComponentPass @@ -218,14 +218,12 @@ fun runCpgAnalyze( val variables = result.variables val callExpressions = result.calls - val nodeInfos = allNodes.map { node: Node -> node.toNodeInfo() } - return CpgAnalysisResult( totalNodes = allNodes.size, functions = functions.size, variables = variables.size, callExpressions = callExpressions.size, - nodes = nodeInfos, + functionSummaries = functions.map { it.toSummary() }, ) } diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgDfgBackwardTool.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgDfgBackwardTool.kt new file mode 100644 index 00000000000..ff2809261be --- /dev/null +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgDfgBackwardTool.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.cpg.mcp.mcpserver.tools + +import de.fraunhofer.aisec.cpg.TranslationResult +import de.fraunhofer.aisec.cpg.graph.Backward +import de.fraunhofer.aisec.cpg.graph.GraphToFollow +import de.fraunhofer.aisec.cpg.graph.Interprocedural +import de.fraunhofer.aisec.cpg.graph.nodes +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgIdPayload +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.runOnCpg +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toObject +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toQueryTreeNode +import de.fraunhofer.aisec.cpg.query.May +import de.fraunhofer.aisec.cpg.query.dataFlow +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlin.uuid.Uuid +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +fun Server.addDfgBackwardTool() { + val toolDescription = + """ + Traverse the Data Flow Graph (DFG) backwards from a given node to find where data originates. + + Uses the CPG's built-in dataflow analysis with backward direction to trace data sources. + The analysis follows prevDFG edges and stops at nodes that have no further incoming data flows. + + Example usage: + - "Where does this value come from?" + + Parameters: + - id: The ID of the node to start the backward traversal from. + """ + .trimIndent() + + val inputSchema = + ToolSchema( + properties = + buildJsonObject { + putJsonObject("id") { + put("type", "string") + put( + "description", + "The ID of the node to start the backward DFG traversal from.", + ) + } + }, + required = listOf("id"), + ) + + this.addTool( + name = "cpg_dfg_backward", + description = toolDescription, + inputSchema = inputSchema, + ) { request -> + request.runOnCpg { result: TranslationResult, request: CallToolRequest -> + val payload = + request.arguments?.toObject() + ?: return@runOnCpg CallToolResult( + content = + listOf( + TextContent("Invalid or missing payload for cpg_dfg_backward tool.") + ) + ) + + val startId = Uuid.parse(payload.id) + val startNode = + result.nodes.find { it.id == startId } + ?: return@runOnCpg CallToolResult( + content = listOf(TextContent("No node found with ID ${payload.id}")) + ) + + val queryTree = + dataFlow( + startNode = startNode, + direction = Backward(GraphToFollow.DFG), + type = May, + scope = Interprocedural(), + predicate = { it.prevDFG.isEmpty() }, + ) + + CallToolResult( + content = listOf(TextContent(Json.encodeToString(queryTree.toQueryTreeNode()))) + ) + } + } +} diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgLlmAnalyzeTool.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgLlmAnalyzeTool.kt index bf0deb320e0..e46bdb22c3f 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgLlmAnalyzeTool.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/CpgLlmAnalyzeTool.kt @@ -26,234 +26,155 @@ package de.fraunhofer.aisec.cpg.mcp.mcpserver.tools import de.fraunhofer.aisec.cpg.mcp.mcpserver.cpgDescription -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgLlmAnalyzePayload import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.getAvailableConcepts import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.getAvailableOperations -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toObject -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toSchema import io.modelcontextprotocol.kotlin.sdk.server.Server -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument +import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.types.Role import io.modelcontextprotocol.kotlin.sdk.types.TextContent -import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlinx.serialization.json.putJsonObject -fun Server.addCpgLlmAnalyzeTool() { - val toolDescription = - """ - Generate a prompt asking the LLM to suggest concepts/operations. - - This tool creates a detailed prompt that asks the LLM to act as a software engineer with expertise in software security - and suggest appropriate concepts and operations for the analyzed code. - After using this tool, review the suggestions before applying them with cpg_apply_concepts. - """ - .trimIndent() - - this.addTool( - name = "cpg_llm_analyze", - description = toolDescription, - inputSchema = CpgLlmAnalyzePayload::class.toSchema(), - // outputSchema = outputSchema - not supported by all LLMs yet - ) { request -> - try { - val payload = - if (request.arguments.isNullOrEmpty()) { - CpgLlmAnalyzePayload() - } else { - request.arguments?.toObject() ?: CpgLlmAnalyzePayload() - } - - val hasAnalysisResult = globalAnalysisResult != null - val availableConcepts = getAvailableConcepts() - val availableOperations = getAvailableOperations() - - val prompt = buildString { - appendLine("# Code Analysis") - appendLine() - appendLine( - "You are a Software engineer with expertise in software security for more than 10 years.\n" - ) - appendLine() - - appendLine("## About CPG") - appendLine(cpgDescription) - appendLine() - - appendLine("## Goal") - appendLine( - "Mark interesting data and operations so we can analyze how data flows through code to discover patterns." +fun Server.addSuggestConceptsPrompt() { + this.addPrompt( + name = "suggest_concepts", + description = + "Generates a prompt that guides the LLM to suggest CPG concepts and operations for the analyzed code. Use this before applying concepts with cpg_apply_concepts.", + arguments = + listOf( + PromptArgument( + name = "description", + description = + "Optional additional context or focus area for the analysis, e.g. 'focus on authentication handling' or 'look for encryption handling'.", + required = false, ) - appendLine() - - appendLine("## Understanding Concepts and Operations") - appendLine() - appendLine("**Concepts** mark 'what something IS':") - appendLine("- Applied to data-holding nodes (variables, fields, return values)") - appendLine("- Examples: user_email → Data, api_token → Secret") - appendLine("- Purpose: Track where important data is stored") - appendLine() - appendLine("**Operations** mark 'what something DOES':") - appendLine("- Applied to nodes that perform actions (function calls, method calls)") - appendLine( - "- Examples: requests.post() → HttpRequest, file.write() → FileWrite, encrypt() → Encryption" - ) - appendLine("- Purpose: Track what happens to important data") - appendLine() + ), + ) { request -> + val description = request.arguments?.get("description") + val availableConcepts = getAvailableConcepts() + val availableOperations = getAvailableOperations() - appendLine("## Rules") - appendLine( - "1. **Domain consistency**: When using Operations, they must match their Concept's domain (e.g., GetSecret needs Secret, HttpRequest needs HttpClient)" - ) - appendLine( - "2. **Operations always need Concepts**: Every Operation MUST have a conceptNodeId pointing to an existing Concept node. However, Concepts can stand alone without Operations." - ) - appendLine() + GetPromptResult( + description = + "Guides the LLM to explore the CPG and suggest concept/operation overlays for security-relevant nodes.", + messages = + listOf( + PromptMessage( + role = Role.User, + content = + TextContent( + text = + buildString { + appendLine("## About CPG") + appendLine(cpgDescription) + appendLine() - appendLine("## Your Task") - appendLine("1. Analyze each node for relevance") - appendLine("2. Suggest appropriate overlays using fully qualified class names") - appendLine("3. Ensure concept-operation pairs belong to the same domain") - appendLine() - appendLine( - "**IMPORTANT:** Use only existing CPG concepts/operations from the list below." - ) - appendLine( - "For additional context, you can check docstrings in the Fraunhofer CPG repository on GitHub, especially the cpg-concepts module." - ) - appendLine() + appendLine("## Goal") + appendLine( + "Identify nodes in the CPG that represent security-relevant data or operations, and suggest appropriate concept/operation overlays for them." + ) + appendLine( + "This allows us to analyze how sensitive data flows through the code and discover security-relevant patterns." + ) + appendLine() - appendLine("Available concepts:") - availableConcepts.forEach { appendLine("- ${it.name}") } - appendLine() - appendLine("Available operations:") - availableOperations.forEach { appendLine("- ${it.name}") } - appendLine() + appendLine("## Understanding Concepts and Operations") + appendLine() + appendLine("**Concepts** mark 'what something IS':") + appendLine( + "- Applied to data-holding nodes (variables, fields)" + ) + appendLine( + "- Examples: user_email → Data, api_token → Secret" + ) + appendLine( + "- Purpose: Track where important data is stored" + ) + appendLine() + appendLine("**Operations** mark 'what something DOES':") + appendLine( + "- Applied to nodes that perform actions (function calls, method calls)" + ) + appendLine( + "- Examples: requests.post() → HttpRequest, file.write() → FileWrite, encrypt() → Encryption" + ) + appendLine( + "- Purpose: Track what happens to important data" + ) + appendLine() - if (payload.description != null) { - appendLine("## Additional Context") - appendLine(payload.description) - appendLine() - } + appendLine("## Rules") + appendLine( + "1. **Domain consistency**: Operations must match their Concept's domain (e.g., GetSecret needs Secret, HttpRequest needs HttpClient)" + ) + appendLine( + "2. **Operations always need Concepts**: Every Operation MUST have a conceptNodeId pointing to an existing Concept node. Concepts can stand alone." + ) + appendLine( + "3. **Use only existing overlays**: Only use the concepts and operations listed below." + ) + appendLine() - if (hasAnalysisResult) { - appendLine("## Nodes to Analyze") - appendLine( - "Use the nodes from the previous cpg_analyze tool response to make your suggestions." - ) - } else { - appendLine("## No Analysis Available") - appendLine("No CPG analysis available. Analyze a file first using cpg_analyze.") - } - appendLine() - appendLine("## Expected Response Format") - appendLine("Respond with a JSON object in this exact format:") - appendLine( - """ - { - "overlaySuggestions": [ - { - "nodeId": "1234", - "overlay": "fully.qualified.class.Name", - "overlayType": "Concept" | "Operation", - "conceptNodeId": "string (REQUIRED for operations)", - "reasoning": "Security reasoning for this classification", - "securityImpact": "Potential security implications" - } - ] - } - """ - .trimIndent() - ) - appendLine() - appendLine( - "**IMPORTANT**: After providing your analysis, WAIT for user approval before applying any concepts. Do not automatically execute cpg_apply_concepts." - ) - } + appendLine("## Available Concepts") + availableConcepts.forEach { appendLine("- ${it.name}") } + appendLine() + appendLine("## Available Operations") + availableOperations.forEach { appendLine("- ${it.name}") } + appendLine() - CallToolResult(content = listOf(TextContent(prompt))) - } catch (e: Exception) { - CallToolResult( - content = - listOf( - TextContent("Error generating prompt: ${e.message ?: e::class.simpleName}") - ) - ) - } - } -} + if (description != null) { + appendLine("## Additional Context") + appendLine(description) + appendLine() + } -// Note: The output schema is not supported by all LLMs yet. -@Suppress("unused") -val outputSchema = - ToolSchema( - properties = - buildJsonObject { - putJsonObject("prompt") { - put("type", "string") - put("description", "Generated prompt for LLM analysis") - } - putJsonObject("expectedResponseFormat") { - put("type", "object") - put("description", "Expected JSON structure for LLM response") - putJsonObject("properties") { - putJsonObject("overlaySuggestions") { - put("type", "array") - put("description", "List of concept/operation suggestions") - putJsonObject("items") { - put("type", "object") - putJsonObject("properties") { - putJsonObject("nodeId") { - put("type", "string") - put("description", "NodeId of the CPG node") - } - putJsonObject("overlay") { - put("type", "string") - put( - "description", - "Fully qualified name of concept or operation class", + appendLine("## How to Explore the Code") + appendLine( + "You can use the tools to explore the code before making suggestions." ) - } - putJsonObject("overlayType") { - put("type", "string") - put( - "description", - "Type of overlay: 'Concept' or 'Operation'", + appendLine( + "The listing tools e.g., `cpg_list_functions` or `cpg_list_records`, provide a summary of functions or classes with the name, parameters and code." ) - } - putJsonObject("conceptNodeId") { - put("type", "string") - put( - "description", - "NodeId of concept this operation references (REQUIRED for ALL operations)", + appendLine( + "To inspect interesting nodes in more detail use `cpg_get_node`. This retrieves the complete details of a specific node filtered by its ID." ) - } - putJsonObject("arguments") { - put("type", "object") - put("description", "Additional constructor arguments") - } - putJsonObject("reasoning") { - put("type", "string") - put( - "description", - "Security reasoning for this classification", + + appendLine("## Expected Response Format") + appendLine( + "After exploring the code, respond with a JSON object in this exact format:" ) - } - putJsonObject("securityImpact") { - put("type", "string") - put("description", "Potential security implications") - } - } - putJsonArray("required") { - add(JsonPrimitive("nodeId")) - add(JsonPrimitive("overlay")) - add(JsonPrimitive("overlayType")) - } - } + appendLine( + """ + { + "conceptAssignments": [ + { + "nodeId": "1234", + "overlay": "fully.qualified.class.Name", + "overlayType": "Concept", + "reasoning": "Reason for this classification", + "securityImpact": "Potential security implications" + }, + { + "nodeId": "5678", + "overlay": "fully.qualified.class.Name", + "overlayType": "Operation", + "conceptNodeId": "1234", + "reasoning": "Reason for this classification", + "securityImpact": "Potential security implications" } + ] } - } - } - ) + """ + .trimIndent() + ) + appendLine() + appendLine( + "**IMPORTANT**: After providing your suggestions, WAIT for user approval before applying anything. Do not automatically call `cpg_apply_concepts`." + ) + } + ), + ) + ), + ) + } +} diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/ListCommands.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/ListCommands.kt index ccba1e47a22..0e55af2a7f7 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/ListCommands.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/ListCommands.kt @@ -27,10 +27,10 @@ package de.fraunhofer.aisec.cpg.mcp.mcpserver.tools import de.fraunhofer.aisec.cpg.TranslationResult import de.fraunhofer.aisec.cpg.graph.* -import de.fraunhofer.aisec.cpg.graph.calls import de.fraunhofer.aisec.cpg.graph.concepts.Concept import de.fraunhofer.aisec.cpg.graph.concepts.Operation import de.fraunhofer.aisec.cpg.graph.invoke +import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.* import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgCallArgumentByNameOrIndexPayload import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgIdPayload import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgNamePayload @@ -38,8 +38,14 @@ import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.addTool import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.runOnCpg import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toJson import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.types.CallToolRequest import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject fun Server.listFunctions() { val toolDescription = @@ -54,7 +60,9 @@ fun Server.listFunctions() { this.addTool(name = "cpg_list_functions", description = toolDescription) { request -> request.runOnCpg { result: TranslationResult, _ -> - CallToolResult(content = result.functions.map { TextContent(it.toJson()) }) + CallToolResult( + content = result.functions.map { TextContent(Json.encodeToString(it.toSummary())) } + ) } } } @@ -62,8 +70,9 @@ fun Server.listFunctions() { fun Server.listRecords() { val toolDescription = """ - This tool lists all classes and structs, more precisely their declarations, which are held in the graph. - + This tool lists all classes and structs, more precisely their declarations as compact summaries. + Use cpg_get_node with a id to retrieve the full node details. + Example prompts: - "Show me all classes in the code" - "What data structures are defined here?" @@ -72,7 +81,9 @@ fun Server.listRecords() { this.addTool(name = "cpg_list_records", description = toolDescription) { request -> request.runOnCpg { result: TranslationResult, _ -> - CallToolResult(content = result.records.map { TextContent(it.toJson()) }) + CallToolResult( + content = result.records.map { TextContent(Json.encodeToString(it.toSummary())) } + ) } } } @@ -96,8 +107,9 @@ fun Server.listConceptsAndOperations() { fun Server.listCalls() { val toolDescription = """ - This tool lists all function and method calls, which are held in the graph. - + This tool lists all function and method calls as compact summaries. + Use cpg_get_node with a id to retrieve the full node details. + Example prompts: - "Show me all function calls in the code" - "What functions are being called?" @@ -106,7 +118,9 @@ fun Server.listCalls() { this.addTool(name = "cpg_list_calls", description = toolDescription) { request -> request.runOnCpg { result: TranslationResult, _ -> - CallToolResult(content = result.calls.map { TextContent(it.toJson()) }) + CallToolResult( + content = result.calls.map { TextContent(Json.encodeToString(it.toSummary())) } + ) } } } @@ -175,3 +189,43 @@ fun Server.getArgByIndexOrName() { ) } } + +fun Server.getNode() { + val toolDescription = + """ + Retrieves the complete information of a single node by its id, including its source code. + Use this after list commands to inspect the actual code and details of specific nodes. + """ + .trimIndent() + + val inputSchema = + ToolSchema( + properties = + buildJsonObject { + putJsonObject("id") { + put("type", "string") + put("description", "The id of the node to retrieve.") + } + }, + required = listOf("id"), + ) + + this.addTool(name = "cpg_get_node", description = toolDescription, inputSchema = inputSchema) { + request -> + request.runOnCpg { result: TranslationResult, request: CallToolRequest -> + val payload = + request.arguments?.toObject() + ?: return@runOnCpg CallToolResult( + content = + listOf(TextContent("Invalid or missing payload for cpg_get_node tool.")) + ) + + val node = result.nodes.find { it.id.toString() == payload.id } + if (node != null) { + CallToolResult(content = listOf(TextContent(node.toJson()))) + } else { + CallToolResult(content = listOf(TextContent("No node found with ${payload.id}"))) + } + } + } +} diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Payloads.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Payloads.kt index 73b2e7b87ac..3c8361910ea 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Payloads.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Payloads.kt @@ -87,6 +87,12 @@ data class ConceptAssignment( val reasoning: String? = null, @Description("A description if this concept could have security implications (optional)") val securityImpact: String? = null, + val name: String? = null, + val type: String? = null, + val code: String? = null, + val fileName: String? = null, + val startLine: Int? = null, + val endLine: Int? = null, ) /** @@ -100,12 +106,6 @@ data class CpgDataflowPayload( @Description("Target concept type (e.g., 'HttpRequest', 'Call')") val to: String, ) -@Serializable -data class CpgLlmAnalyzePayload( - @Description("A special description of what to take care of while analyzing the target") - val description: String? = null -) - /** * This class represents information about a pass, including its fully qualified name (FQN), a * description, required node type, dependencies, and soft dependencies. diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Serializable.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Serializable.kt index 0fe9988614d..310dc716f21 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Serializable.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Serializable.kt @@ -25,43 +25,10 @@ */ package de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils -import de.fraunhofer.aisec.cpg.graph.Node import de.fraunhofer.aisec.cpg.graph.OverlayNode -import de.fraunhofer.aisec.cpg.graph.declarations.Field -import de.fraunhofer.aisec.cpg.graph.declarations.Function -import de.fraunhofer.aisec.cpg.graph.declarations.Parameter -import de.fraunhofer.aisec.cpg.graph.declarations.Record -import de.fraunhofer.aisec.cpg.graph.statements.expressions.Call -import de.fraunhofer.aisec.cpg.graph.types.Type +import de.fraunhofer.aisec.cpg.serialization.NodeJSON import kotlinx.serialization.Serializable -@Serializable -data class NodeInfo( - val nodeId: String, - val name: String, - val code: String?, - val type: String?, - val fileName: String?, - val startLine: Int?, - val endLine: Int?, - val startColumn: Int?, - val endColumn: Int?, -) { - constructor( - node: Node - ) : this( - nodeId = node.id.toString(), - name = node.name.localName, - code = node.code, - type = node::class.simpleName, - fileName = node.location?.artifactLocation?.fileName, - startLine = node.location?.region?.startLine, - endLine = node.location?.region?.endLine, - startColumn = node.location?.region?.startColumn, - endColumn = node.location?.region?.endColumn, - ) -} - @Serializable data class OverlayInfo( val nodeId: String, @@ -91,127 +58,46 @@ data class OverlayInfo( ) } -@Serializable -data class TypeInfo(val name: String) { - constructor(type: Type) : this(type.name.toString()) -} - -@Serializable -data class ParameterInfo(val name: String, val type: TypeInfo, val defaultValue: String? = null) { - constructor( - parameterDeclaration: Parameter - ) : this( - name = parameterDeclaration.name.toString(), - type = TypeInfo(parameterDeclaration.type), - defaultValue = parameterDeclaration.default.toString(), - ) -} - -@Serializable -data class FunctionInfo( - val nodeId: String, - val name: String, - val parameters: List, - val signature: String, - val fileName: String?, - val startLine: Int?, - val endLine: Int?, - val startColumn: Int?, - val endColumn: Int?, -) { - constructor( - functionDeclaration: Function - ) : this( - nodeId = functionDeclaration.id.toString(), - name = functionDeclaration.name.toString(), - parameters = functionDeclaration.parameters.map { ParameterInfo(it) }, - signature = functionDeclaration.signature, - fileName = functionDeclaration.location?.artifactLocation?.fileName, - startLine = functionDeclaration.location?.region?.startLine, - endLine = functionDeclaration.location?.region?.endLine, - startColumn = functionDeclaration.location?.region?.startColumn, - endColumn = functionDeclaration.location?.region?.endColumn, - ) -} +@Serializable data class ParameterInfo(val id: String, val name: String, val type: String) @Serializable -data class CallInfo( - val nodeId: String, +data class FunctionSummary( + val id: String, val name: String, - val arguments: List, - val resolvedTo: List, val fileName: String?, val startLine: Int?, val endLine: Int?, - val startColumn: Int?, - val endColumn: Int?, -) { - constructor( - callExpression: Call - ) : this( - nodeId = callExpression.id.toString(), - name = callExpression.name.toString(), - arguments = callExpression.arguments.map { NodeInfo(it) }, - resolvedTo = callExpression.invokes.map { FunctionInfo(it) }, - fileName = callExpression.location?.artifactLocation?.fileName, - startLine = callExpression.location?.region?.startLine, - endLine = callExpression.location?.region?.endLine, - startColumn = callExpression.location?.region?.startColumn, - endColumn = callExpression.location?.region?.endColumn, - ) -} + val parameters: List, + val returnType: String?, + val callees: List, + val code: String?, + val translationUnitId: String?, +) @Serializable -data class RecordInfo( - val nodeId: String, +data class RecordSummary( + val id: String, val name: String, - val methods: List, - val fields: List, val fileName: String?, val startLine: Int?, val endLine: Int?, - val startColumn: Int?, - val endColumn: Int?, -) { - constructor( - recordDeclaration: Record - ) : this( - nodeId = recordDeclaration.id.toString(), - name = recordDeclaration.name.toString(), - methods = recordDeclaration.methods.map { FunctionInfo(it) }, - fields = recordDeclaration.fields.map { FieldInfo(it) }, - fileName = recordDeclaration.location?.artifactLocation?.fileName, - startLine = recordDeclaration.location?.region?.startLine, - endLine = recordDeclaration.location?.region?.endLine, - startColumn = recordDeclaration.location?.region?.startColumn, - endColumn = recordDeclaration.location?.region?.endColumn, - ) -} + val kind: String?, + val fieldCount: Int, + val methodNames: List, + val translationUnitId: String?, +) @Serializable -data class FieldInfo( - val nodeId: String, +data class CallSummary( + val id: String, val name: String, - val type: TypeInfo, val fileName: String?, val startLine: Int?, val endLine: Int?, - val startColumn: Int?, - val endColumn: Int?, -) { - constructor( - field: Field - ) : this( - nodeId = field.id.toString(), - name = field.name.toString(), - type = TypeInfo(field.type), - fileName = field.location?.artifactLocation?.fileName, - startLine = field.location?.region?.startLine, - endLine = field.location?.region?.endLine, - startColumn = field.location?.region?.startColumn, - endColumn = field.location?.region?.endColumn, - ) -} + val arguments: List, + val code: String?, + val translationUnitId: String?, +) @Serializable data class CpgAnalysisResult( @@ -219,7 +105,7 @@ data class CpgAnalysisResult( val functions: Int, val variables: Int, val callExpressions: Int, - val nodes: List, + val functionSummaries: List, ) @Serializable @@ -231,8 +117,8 @@ data class DataflowResult( @Serializable data class QueryTreeNode( - val id: String, + val queryTreeId: String, val value: String, - val node: NodeInfo?, + val node: NodeJSON?, val children: List = emptyList(), ) diff --git a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Util.kt b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Util.kt index bde4e1aaff9..845b3646cd7 100644 --- a/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Util.kt +++ b/cpg-mcp/src/main/kotlin/de/fraunhofer/aisec/cpg/mcp/mcpserver/tools/utils/Util.kt @@ -26,11 +26,12 @@ package de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils import de.fraunhofer.aisec.cpg.TranslationResult +import de.fraunhofer.aisec.cpg.graph.* import de.fraunhofer.aisec.cpg.graph.Node import de.fraunhofer.aisec.cpg.graph.OverlayNode +import de.fraunhofer.aisec.cpg.graph.callees import de.fraunhofer.aisec.cpg.graph.concepts.Concept import de.fraunhofer.aisec.cpg.graph.concepts.Operation -import de.fraunhofer.aisec.cpg.graph.declarations.Field import de.fraunhofer.aisec.cpg.graph.declarations.Function import de.fraunhofer.aisec.cpg.graph.declarations.Record import de.fraunhofer.aisec.cpg.graph.listOverlayClasses @@ -38,6 +39,7 @@ import de.fraunhofer.aisec.cpg.graph.statements.expressions.Call import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.globalAnalysisResult import de.fraunhofer.aisec.cpg.passes.Description import de.fraunhofer.aisec.cpg.query.QueryTree +import de.fraunhofer.aisec.cpg.serialization.toJSON import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.TextContent @@ -197,30 +199,65 @@ fun KClass<*>.toSchema(): ToolSchema { return ToolSchema(properties = properties, required = required) } -fun Node.toNodeInfo(): NodeInfo { - return NodeInfo(this) -} - fun QueryTree.toQueryTreeNode(): QueryTreeNode { return QueryTreeNode( - id = this.id.toString(), + queryTreeId = this.id.toString(), value = this.value.toString(), - node = this.node?.toNodeInfo(), + node = this.node?.toJSON(noEdges = false), children = this.children.map { it.toQueryTreeNode() }, ) } -fun Node.toJson() = Json.encodeToString(NodeInfo(this)) - -fun Function.toJson() = Json.encodeToString(FunctionInfo(this)) +/** Converts any [Node] to a JSON string using the [NodeJSON] format. */ +fun Node.toJson() = Json.encodeToString(this.toJSON()) -fun Field.toJson() = Json.encodeToString(FieldInfo(this)) +fun OverlayNode.toJson() = Json.encodeToString(OverlayInfo(this)) -fun Record.toJson() = Json.encodeToString(RecordInfo(this)) +fun Function.toSummary() = + FunctionSummary( + id = this.id.toString(), + name = this.name.localName, + fileName = this.location?.artifactLocation?.fileName, + startLine = this.location?.region?.startLine, + endLine = this.location?.region?.endLine, + parameters = + this.parameters.map { + ParameterInfo( + id = it.id.toString(), + name = it.name.localName, + type = it.type.typeName, + ) + }, + returnType = this.returnTypes.firstOrNull()?.typeName, + callees = this.callees.map { it.name.localName }, + code = this.code, + translationUnitId = this.translationUnit?.id?.toString(), + ) -fun Call.toJson() = Json.encodeToString(CallInfo(this)) +fun Record.toSummary() = + RecordSummary( + id = this.id.toString(), + name = this.name.localName, + fileName = this.location?.artifactLocation?.fileName, + startLine = this.location?.region?.startLine, + endLine = this.location?.region?.endLine, + kind = this.kind, + fieldCount = this.fields.size, + methodNames = this.methods.map { it.name.localName }, + translationUnitId = this.translationUnit?.id?.toString(), + ) -fun OverlayNode.toJson() = Json.encodeToString(OverlayInfo(this)) +fun Call.toSummary() = + CallSummary( + id = this.id.toString(), + name = this.name.localName, + fileName = this.location?.artifactLocation?.fileName, + startLine = this.location?.region?.startLine, + endLine = this.location?.region?.endLine, + arguments = this.arguments.map { "${it.type.typeName} ${it.name.localName}" }, + code = this.code, + translationUnitId = this.translationUnit?.id?.toString(), + ) /** Returns all available concrete (non-abstract) concept classes. */ fun getAvailableConcepts(): List> { @@ -244,7 +281,10 @@ fun getAvailableOperations(): List> { } } -inline fun JsonObject.toObject() = Json.decodeFromString(Json.encodeToString(this)) +@PublishedApi internal val lenientJson = Json { ignoreUnknownKeys = true } + +inline fun JsonObject.toObject() = + lenientJson.decodeFromString(Json.encodeToString(this)) inline fun T.runOnCpg( query: BiFunction diff --git a/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/CpgAnalyzeToolTest.kt b/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/CpgAnalyzeToolTest.kt index 5160f9588b0..34ba566df19 100644 --- a/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/CpgAnalyzeToolTest.kt +++ b/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/CpgAnalyzeToolTest.kt @@ -25,48 +25,17 @@ */ package de.fraunhofer.aisec.cpg.mcp -import de.fraunhofer.aisec.cpg.mcp.mcpserver.configureServer import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.ctx import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.globalAnalysisResult import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.runCpgAnalyze import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgAnalyzePayload import io.modelcontextprotocol.kotlin.sdk.* -import io.modelcontextprotocol.kotlin.sdk.server.Server import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame class CpgAnalyzeToolTest { - private lateinit var server: Server - - @Test - fun testConfigureServer() { - val testServer = configureServer() - assertEquals( - setOf( - "cpg_translate", - "cpg_list_passes", - "cpg_run_pass", - "cpg_analyze", - "cpg_llm_analyze", - "cpg_apply_concepts", - "cpg_dataflow", - "cpg_list_functions", - "cpg_list_records", - "cpg_list_concepts_and_operations", - "cpg_list_calls", - "cpg_list_calls_to", - "cpg_list_call_args", - "cpg_list_call_arg_by_name_or_index", - "cpg_list_available_concepts", - "cpg_list_available_operations", - ), - testServer.tools.keys, - ) - } - @Test fun testReanalyze() { // Build a small CPG without passes diff --git a/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/JsonSchemaGeneratorTest.kt b/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/JsonSchemaGeneratorTest.kt index bb8cd83c301..f5690852948 100644 --- a/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/JsonSchemaGeneratorTest.kt +++ b/cpg-mcp/src/test/kotlin/de/fraunhofer/aisec/cpg/mcp/JsonSchemaGeneratorTest.kt @@ -25,11 +25,12 @@ */ package de.fraunhofer.aisec.cpg.mcp -import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.CpgApplyConceptsPayload import de.fraunhofer.aisec.cpg.mcp.mcpserver.tools.utils.toSchema +import de.fraunhofer.aisec.cpg.passes.Description import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlin.test.Test import kotlin.test.assertEquals +import kotlinx.serialization.Serializable import kotlinx.serialization.json.add import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -52,27 +53,14 @@ class JsonSchemaGeneratorTest { putJsonObject("properties") { putJsonObject("arguments") { put("type", "array") - put( - "description", - "Additional constructor arguments (optional)", - ) putJsonObject("items") { put("type", "object") putJsonObject("properties") { putJsonObject("key") { put("type", "string") - put( - "description", - "The key of the key-value pair", - ) - } - putJsonObject("value") { - put("type", "string") - put( - "description", - "The value of the key-value pair", - ) + put("description", "The key") } + putJsonObject("value") { put("type", "string") } } putJsonArray("required") { add("key") @@ -80,37 +68,9 @@ class JsonSchemaGeneratorTest { } } } - putJsonObject("conceptNodeId") { + putJsonObject("id") { put("type", "string") - put( - "description", - "NodeId of the concept this operation references (only for operations)", - ) - } - putJsonObject("nodeId") { - put("type", "string") - put("description", "ID of the node to apply overlay to") - } - putJsonObject("overlay") { - put("type", "string") - put( - "description", - "Fully qualified name of concept or operation class", - ) - } - putJsonObject("overlayType") { - put("type", "string") - put( - "description", - "Type of overlay: 'Concept' or 'Operation'", - ) - } - putJsonObject("reasoning") { - put("type", "string") - put( - "description", - "Reasoning for applying this concept/operation (optional)", - ) + put("description", "Required id") } putJsonObject("securityImpact") { put("type", "string") @@ -120,18 +80,31 @@ class JsonSchemaGeneratorTest { ) } } - putJsonArray("required") { - add("nodeId") - add("overlay") - } + putJsonArray("required") { add("id") } } } }, required = listOf("assignments"), ) - val actual = CpgApplyConceptsPayload::class.toSchema() + val actual = TestPayload::class.toSchema() assertEquals(expected, actual) } } + +@Serializable +private data class TestPair(@Description("The key") val key: String, val value: String) + +@Serializable +private data class TestAssignment( + @Description("Required id") val id: String, + @Description("A description if this concept could have security implications (optional)") + val securityImpact: String? = null, + val arguments: List? = null, +) + +@Serializable +private data class TestPayload( + @Description("List of concept assignments to perform") val assignments: List +) diff --git a/cpg-serialization/build.gradle.kts b/cpg-serialization/build.gradle.kts new file mode 100644 index 00000000000..b8d2731f753 --- /dev/null +++ b/cpg-serialization/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +plugins { + id("cpg.library-conventions") + kotlin("plugin.serialization") +} + +mavenPublishing { + pom { + name.set("Code Property Graph - Serialization") + description.set("JSON serialization for CPG nodes") + } +} + +dependencies { + api(project(":cpg-core")) + api(libs.kotlinx.serialization.json) +} diff --git a/cpg-serialization/src/main/kotlin/de/fraunhofer/aisec/cpg/serialization/NodeSerialization.kt b/cpg-serialization/src/main/kotlin/de/fraunhofer/aisec/cpg/serialization/NodeSerialization.kt new file mode 100644 index 00000000000..46cb1979777 --- /dev/null +++ b/cpg-serialization/src/main/kotlin/de/fraunhofer/aisec/cpg/serialization/NodeSerialization.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.cpg.serialization + +import de.fraunhofer.aisec.cpg.graph.Node +import de.fraunhofer.aisec.cpg.graph.component +import de.fraunhofer.aisec.cpg.graph.edges.Edge +import de.fraunhofer.aisec.cpg.graph.translationUnit +import kotlin.uuid.Uuid +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Custom serializer for [Uuid] to convert it to and from a string representation. This is used for + * serialization and deserialization of [Uuid] in the JSON data classes. + */ +object UuidSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Uuid", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Uuid) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Uuid { + return Uuid.parse(decoder.decodeString()) + } +} + +/** JSON data class for an [Edge]. */ +@Serializable +data class EdgeJSON( + var label: String, + @Serializable(with = UuidSerializer::class) var start: Uuid, + @Serializable(with = UuidSerializer::class) var end: Uuid, +) + +/** JSON data class for a [Node]. */ +@Serializable +data class NodeJSON( + @Serializable(with = UuidSerializer::class) val id: Uuid, + val type: String, + val startLine: Int, + val startColumn: Int, + val endLine: Int, + val endColumn: Int, + val code: String, + val name: String, + // val astChildren: List, + val prevDFG: List = emptyList(), + val nextDFG: List = emptyList(), + @Serializable(with = UuidSerializer::class) val translationUnitId: Uuid? = null, + val componentName: String? = null, + val fileName: String? = null, +) + +/** Converts a [Node] into its JSON representation. */ +fun Node.toJSON(noEdges: Boolean = false): NodeJSON { + return NodeJSON( + id = this.id, + type = this.javaClass.simpleName, + startLine = location?.region?.startLine ?: -1, + startColumn = location?.region?.startColumn ?: -1, + endLine = location?.region?.endLine ?: -1, + endColumn = location?.region?.endColumn ?: -1, + code = this.code ?: "", + name = this.name.toString(), + fileName = + this.location?.artifactLocation?.uri?.let { uri -> + // Extract filename from URI + val path = uri.toString() + path.substringAfterLast('/').substringAfterLast('\\') + }, + // astChildren = + // if (noEdges) emptyList() + // else (this as? AstNode)?.astChildren?.map { it.toJSON() } ?: + // emptyList(), + prevDFG = if (noEdges) emptyList() else this.prevDFGEdges.map { it.toJSON() }, + nextDFG = if (noEdges) emptyList() else this.nextDFGEdges.map { it.toJSON() }, + translationUnitId = this.translationUnit?.id, + componentName = this.component?.name?.toString(), + ) +} + +/** Converts an [Edge] into its JSON representation. */ +fun Edge<*>.toJSON(): EdgeJSON { + return EdgeJSON( + label = this.labels.firstOrNull() ?: "", + start = this.start.id, + end = this.end.id, + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 382e5f2661d..2e18b1aba4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,9 +11,10 @@ slf4j = "2.0.16" clikt = "5.1.0" kaml = "0.104.0" sarif4k = "0.6.0" -ktor = "3.4.0" +ktor = "3.3.0" node = "22.14.0" deno-plugin = "0.1.5" +koog = "0.6.2" [libraries] kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin"} @@ -66,8 +67,15 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } +ktor-server-sse = { module = "io.ktor:ktor-server-sse", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } mcp = {module = "io.modelcontextprotocol:kotlin-sdk", version = "0.8.4"} +mcp-client = {module = "io.modelcontextprotocol:kotlin-sdk-client", version = "0.8.4"} + +koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" } +koog-agents-mcp = { module = "ai.koog:agents-mcp", version.ref = "koog" } # test junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version = "5.14.1"} @@ -94,7 +102,8 @@ dex2jar = {module = "de.femtopedia.dex2jar:dex2jar", version="2.4.22" } log4j = ["log4j-impl", "log4j-core"] neo4j = ["neo4j-ogm-core", "neo4j-ogm-bolt-driver"] sootup = ["sootup-core", "sootup-java-core", "sootup-java-sourcecode", "sootup-java-bytecode", "sootup-jimple", "sootup-apk"] -ktor = ["ktor-server-core", "ktor-server-netty", "ktor-server-cors","ktor-server-content-negotiation", "ktor-serialization-kotlinx-json"] +ktor = ["ktor-server-core", "ktor-server-netty", "ktor-server-cors","ktor-server-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-server-sse"] +ktor-client = ["ktor-client-core", "ktor-client-cio", "ktor-client-content-negotiation"] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0c7ec80365d..c47988d97ad 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ include(":cpg-core") include(":cpg-analysis") include(":cpg-neo4j") include(":cpg-concepts") +include(":cpg-serialization") include(":codyze") include(":codyze-core")