From 32222faa7322671456bd228bd1148ffb393ef478 Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Thu, 28 May 2026 22:11:21 -0300 Subject: [PATCH 01/19] =?UTF-8?q?Add=20MCP=20blackbox=20sampler=20?= =?UTF-8?q?=E2=80=94=20Phases=201=E2=80=934?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new problem domain for blackbox testing of MCP (Model Context Protocol) servers, following the same architecture as the existing GraphQL and RPC problem domains. New components under core/src/main/kotlin/.../problem/mcp/: - McpAction (abstract base), McpToolCallAction (tools/call), McpResourceReadAction (resources/read), McpInputParam, McpUriParam - McpIndividual: chromosome wrapping sequences of MCP actions - McpCallResult: per-action execution record storing isError flag - client/McpClient (interface), client/HttpMcpClient (JSON-RPC 2.0 over HTTP with pagination), client/McpDTOs - service/McpSampler: @PostConstruct discovery via tools/list, resources/list, resources/templates/list; gene tree building from JSON Schema; random and smart (ad-hoc) sampling - service/McpFitness (abstract base), service/McpBlackBoxFitness: per-action scoring using isError flag and successful reads as signals - service/McpBlackBoxModule: Guice bindings mirroring GraphQLBlackBoxModule Unit tests cover action construction, individual copy/mutation, and HttpMcpClient JSON-RPC parsing via WireMock stubs. --- .../evomaster/core/problem/mcp/McpAction.kt | 18 ++ .../core/problem/mcp/McpCallResult.kt | 26 +++ .../core/problem/mcp/McpIndividual.kt | 48 ++++ .../core/problem/mcp/McpInputParam.kt | 8 + .../core/problem/mcp/McpResourceReadAction.kt | 16 ++ .../core/problem/mcp/McpToolCallAction.kt | 17 ++ .../evomaster/core/problem/mcp/McpUriParam.kt | 8 + .../core/problem/mcp/client/HttpMcpClient.kt | 142 ++++++++++++ .../core/problem/mcp/client/McpClient.kt | 9 + .../core/problem/mcp/client/McpDTOs.kt | 36 +++ .../problem/mcp/service/McpBlackBoxFitness.kt | 128 +++++++++++ .../problem/mcp/service/McpBlackBoxModule.kt | 82 +++++++ .../core/problem/mcp/service/McpFitness.kt | 11 + .../core/problem/mcp/service/McpSampler.kt | 210 ++++++++++++++++++ .../core/problem/mcp/McpActionTest.kt | 56 +++++ .../core/problem/mcp/McpIndividualTest.kt | 78 +++++++ .../problem/mcp/client/HttpMcpClientTest.kt | 183 +++++++++++++++ 17 files changed, 1076 insertions(+) create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt create mode 100644 core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt create mode 100644 core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt create mode 100644 core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt new file mode 100644 index 0000000000..3762b170c1 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt @@ -0,0 +1,18 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.search.action.MainAction +import org.evomaster.core.search.gene.Gene + +abstract class McpAction( + val id: String, + parameters: MutableList +) : MainAction(false, parameters) { + + val parameters: List + get() = children as List + + override fun getName(): String = id + + override fun seeTopGenes(): List = parameters.flatMap { it.seeGenes() } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt new file mode 100644 index 0000000000..dae50b2449 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt @@ -0,0 +1,26 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.search.action.ActionResult + +class McpCallResult : ActionResult { + + companion object { + const val IS_ERROR = "IS_ERROR" + } + + constructor(sourceLocalId: String) : super(sourceLocalId) + + /** + * Copy constructor. Delegates to the [ActionResult] copy constructor + * which propagates [stopping], [deathSentence], and the results map. + */ + private constructor(other: McpCallResult) : super(other) + + override fun copy(): McpCallResult = McpCallResult(this) + + fun setIsError(isError: Boolean) { + addResultValue(IS_ERROR, isError.toString()) + } + + fun getIsError(): Boolean = getResultValue(IS_ERROR)?.toBoolean() ?: false +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt new file mode 100644 index 0000000000..0129824e1e --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt @@ -0,0 +1,48 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.ApiWsIndividual +import org.evomaster.core.problem.enterprise.EnterpriseChildTypeVerifier +import org.evomaster.core.problem.enterprise.EnterpriseIndividual.Companion.getEnterpriseTopGroups +import org.evomaster.core.problem.enterprise.SampleType +import org.evomaster.core.search.GroupsOfChildren +import org.evomaster.core.search.Individual +import org.evomaster.core.search.StructuralElement +import org.evomaster.core.search.action.ActionComponent + +class McpIndividual( + sampleType: SampleType, + allActions: MutableList, + mainSize: Int = allActions.size, + sqlSize: Int = 0, + mongoSize: Int = 0, + groups: GroupsOfChildren = + getEnterpriseTopGroups(allActions, mainSize, sqlSize, mongoSize, 0, 0, 0, 0) +) : ApiWsIndividual( + sampleType = sampleType, + children = allActions, + childTypeVerifier = EnterpriseChildTypeVerifier(McpAction::class.java), + groups = groups +) { + + override fun copyContent(): Individual { + return McpIndividual( + sampleType, + children.map { it.copy() }.toMutableList() as MutableList, + mainSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.MAIN), + sqlSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_SQL), + mongoSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_MONGO) + ) + } + + fun addMcpAction(relativePosition: Int = -1, action: McpAction) { + addMainActionInEmptyEnterpriseGroup(relativePosition, action) + } + + fun removeMcpActionAt(relativePosition: Int) { + killChildByIndex(relativePosition) + } + + override fun seeMainExecutableActions(): List { + return super.seeMainExecutableActions() as List + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt new file mode 100644 index 0000000000..035ab3d118 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt @@ -0,0 +1,8 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.search.gene.Gene + +class McpInputParam(name: String, gene: Gene) : Param(name, gene) { + override fun copyContent(): Param = McpInputParam(name, gene.copy()) +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt new file mode 100644 index 0000000000..99f28194c6 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt @@ -0,0 +1,16 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.search.action.Action +import org.evomaster.core.search.gene.string.StringGene + +class McpResourceReadAction( + val uri: StringGene, + val isTemplate: Boolean = false +) : McpAction( + id = "resource:${uri.value}", + parameters = mutableListOf(McpUriParam("uri", uri)) +) { + override fun copyContent(): Action { + return McpResourceReadAction(uri.copy() as StringGene, isTemplate) + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt new file mode 100644 index 0000000000..bd4ebdda1a --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt @@ -0,0 +1,17 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.search.action.Action +import org.evomaster.core.search.gene.ObjectGene + +class McpToolCallAction( + val toolName: String, + val inputSchema: ObjectGene, + val description: String = "" +) : McpAction( + id = "tool:$toolName", + parameters = mutableListOf(McpInputParam("input", inputSchema)) +) { + override fun copyContent(): Action { + return McpToolCallAction(toolName, inputSchema.copy() as ObjectGene, description) + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt new file mode 100644 index 0000000000..46d9bbdcb7 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt @@ -0,0 +1,8 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.search.gene.Gene + +class McpUriParam(name: String, gene: Gene) : Param(name, gene) { + override fun copyContent(): Param = McpUriParam(name, gene.copy()) +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt new file mode 100644 index 0000000000..c6bfff068c --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -0,0 +1,142 @@ +package org.evomaster.core.problem.mcp.client + +import com.fasterxml.jackson.databind.ObjectMapper +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.atomic.AtomicInteger + +class HttpMcpClient(private val baseUrl: String) : McpClient { + + private val mapper: ObjectMapper = ObjectMapper() + private val idCounter = AtomicInteger(1) + + private fun nextId() = idCounter.getAndIncrement() + + private fun post(method: String, params: Map = emptyMap()): Map { + val body = mapper.writeValueAsString( + mapOf( + "jsonrpc" to "2.0", + "method" to method, + "params" to params, + "id" to nextId() + ) + ) + val url = URL(baseUrl) + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", "application/json") + conn.doOutput = true + conn.connectTimeout = 10_000 + conn.readTimeout = 30_000 + conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + val responseBody = conn.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + @Suppress("UNCHECKED_CAST") + return mapper.readValue(responseBody, Map::class.java) as Map + } + + @Suppress("UNCHECKED_CAST") + override fun listTools(): List { + val tools = mutableListOf() + var cursor: String? = null + do { + val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() + val response = post("tools/list", params) + val result = response["result"] as? Map ?: break + val items = result["tools"] as? List<*> ?: emptyList() + items.filterIsInstance>().forEach { t -> + tools.add( + McpToolDefinition( + name = t["name"] as? String ?: "", + description = t["description"] as? String ?: "", + inputSchema = t["inputSchema"] as? Map ?: emptyMap() + ) + ) + } + cursor = result["nextCursor"] as? String + } while (cursor != null) + return tools + } + + @Suppress("UNCHECKED_CAST") + override fun listResources(): List { + val resources = mutableListOf() + var cursor: String? = null + do { + val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() + val response = post("resources/list", params) + val result = response["result"] as? Map ?: break + val items = result["resources"] as? List<*> ?: emptyList() + items.filterIsInstance>().forEach { r -> + resources.add( + McpResourceDefinition( + uri = r["uri"] as? String ?: "", + name = r["name"] as? String ?: "", + description = r["description"] as? String ?: "", + mimeType = r["mimeType"] as? String + ) + ) + } + cursor = result["nextCursor"] as? String + } while (cursor != null) + return resources + } + + @Suppress("UNCHECKED_CAST") + override fun listResourceTemplates(): List { + val templates = mutableListOf() + var cursor: String? = null + do { + val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() + val response = post("resources/templates/list", params) + val result = response["result"] as? Map ?: break + val items = result["resourceTemplates"] as? List<*> ?: emptyList() + items.filterIsInstance>().forEach { t -> + templates.add( + McpResourceTemplate( + uriTemplate = t["uriTemplate"] as? String ?: "", + name = t["name"] as? String ?: "", + description = t["description"] as? String ?: "" + ) + ) + } + cursor = result["nextCursor"] as? String + } while (cursor != null) + return templates + } + + @Suppress("UNCHECKED_CAST") + override fun callTool(name: String, arguments: Map): McpToolResult { + val response = post("tools/call", mapOf("name" to name, "arguments" to arguments)) + val result = response["result"] as? Map ?: return McpToolResult(isError = true) + val rawContent = result["content"] as? List<*> ?: emptyList() + val content = rawContent.filterIsInstance>().map { c -> + McpContent( + type = c["type"] as? String ?: "text", + text = c["text"] as? String, + uri = c["uri"] as? String, + mimeType = c["mimeType"] as? String + ) + } + return McpToolResult( + content = content, + isError = result["isError"] as? Boolean ?: false + ) + } + + @Suppress("UNCHECKED_CAST") + override fun readResource(uri: String): McpResourceResult { + val response = post("resources/read", mapOf("uri" to uri)) + val result = response["result"] as? Map ?: return McpResourceResult() + val rawContents = result["contents"] as? List<*> ?: emptyList() + val contents = rawContents.filterIsInstance>().map { c -> + McpContent( + type = c["type"] as? String ?: "text", + text = c["text"] as? String, + uri = c["uri"] as? String, + mimeType = c["mimeType"] as? String + ) + } + return McpResourceResult(contents = contents) + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt new file mode 100644 index 0000000000..8f0cc0cda7 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt @@ -0,0 +1,9 @@ +package org.evomaster.core.problem.mcp.client + +interface McpClient { + fun listTools(): List + fun listResources(): List + fun listResourceTemplates(): List + fun callTool(name: String, arguments: Map): McpToolResult + fun readResource(uri: String): McpResourceResult +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt new file mode 100644 index 0000000000..5fea8e0f36 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt @@ -0,0 +1,36 @@ +package org.evomaster.core.problem.mcp.client + +data class McpToolDefinition( + val name: String, + val description: String = "", + val inputSchema: Map = emptyMap() +) + +data class McpResourceDefinition( + val uri: String, + val name: String = "", + val description: String = "", + val mimeType: String? = null +) + +data class McpResourceTemplate( + val uriTemplate: String, + val name: String = "", + val description: String = "" +) + +data class McpToolResult( + val content: List = emptyList(), + val isError: Boolean = false +) + +data class McpResourceResult( + val contents: List = emptyList() +) + +data class McpContent( + val type: String, + val text: String? = null, + val uri: String? = null, + val mimeType: String? = null +) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt new file mode 100644 index 0000000000..01c4a0e5e2 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt @@ -0,0 +1,128 @@ +package org.evomaster.core.problem.mcp.service + +import com.google.inject.Inject +import org.evomaster.core.problem.mcp.McpCallResult +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.problem.mcp.McpResourceReadAction +import org.evomaster.core.problem.mcp.McpToolCallAction +import org.evomaster.core.search.EvaluatedIndividual +import org.evomaster.core.search.FitnessValue +import org.evomaster.core.search.action.ActionResult +import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.Gene +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.collection.ArrayGene +import org.evomaster.core.search.gene.numeric.NumberGene +import org.evomaster.core.search.gene.string.StringGene +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +/** + * Blackbox fitness function for MCP servers. + * + * Executes each action in an [McpIndividual] sequentially via the [McpSampler]'s client + * and scores coverage targets based on observable protocol signals (isError flag, + * successful reads). Mirrors [GraphQLBlackBoxFitness] in structure. + */ +class McpBlackBoxFitness : McpFitness() { + + companion object { + private val log: Logger = LoggerFactory.getLogger(McpBlackBoxFitness::class.java) + } + + @Inject + private lateinit var sampler: McpSampler + + override fun doCalculateCoverage( + individual: McpIndividual, + targets: Set, + allTargets: Boolean, + fullyCovered: Boolean, + descriptiveIds: Boolean, + ): EvaluatedIndividual? { + + val fv = FitnessValue(individual.size().toDouble()) + val actionResults: MutableList = mutableListOf() + val client = sampler.getMcpClient() + + val actions = individual.seeMainExecutableActions() + + for (i in actions.indices) { + val action = actions[i] + val result = McpCallResult(action.getLocalId()) + actionResults.add(result) + + try { + when (action) { + is McpToolCallAction -> { + val args = geneToMap(action.inputSchema) + val toolResult = client.callTool(action.toolName, args) + + result.setIsError(toolResult.isError) + result.stopping = false + + val targetId = idMapper.handleLocalTarget("tool:${action.toolName}") + val score = if (!toolResult.isError) 1.0 else 0.5 + fv.updateTarget(targetId, score, i) + } + + is McpResourceReadAction -> { + val resourceResult = client.readResource(action.uri.value) + result.setIsError(false) + result.stopping = false + + val targetId = idMapper.handleLocalTarget("resource:${action.id}") + // A successful read (even empty contents) scores 1.0 + fv.updateTarget(targetId, 1.0, i) + } + + else -> { + result.stopping = false + } + } + } catch (e: Exception) { + log.warn("Exception evaluating MCP action ${action.id}: ${e.message}") + result.setIsError(true) + result.stopping = true + break + } + } + + return EvaluatedIndividual( + fv, + individual.copy() as McpIndividual, + actionResults, + trackOperator = individual.trackOperator, + index = time.evaluatedIndividuals, + config = config + ) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Convert an [ObjectGene] to a [Map] of argument values suitable for passing + * to [McpClient.callTool]. Uses typed gene values when available, falling + * back to printable string representation for complex types. + */ + private fun geneToMap(gene: ObjectGene): Map { + val map = mutableMapOf() + for (field in gene.fields) { + map[field.name] = extractGeneValue(field) + } + return map + } + + private fun extractGeneValue(gene: Gene): Any? { + return when (gene) { + is StringGene -> gene.value + is BooleanGene -> gene.value + is NumberGene<*> -> gene.value + is ObjectGene -> geneToMap(gene) + is ArrayGene<*> -> gene.getViewOfElements().map { extractGeneValue(it) } + else -> gene.getValueAsPrintableString(listOf(), null, null, false) + } + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt new file mode 100644 index 0000000000..10356a8650 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt @@ -0,0 +1,82 @@ +package org.evomaster.core.problem.mcp.service + +import com.google.inject.AbstractModule +import com.google.inject.TypeLiteral +import org.evomaster.core.problem.enterprise.service.EnterpriseSampler +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.remote.service.RemoteController +import org.evomaster.core.remote.service.RemoteControllerImplementation +import org.evomaster.core.search.service.Archive +import org.evomaster.core.search.service.FitnessFunction +import org.evomaster.core.search.service.FlakinessDetector +import org.evomaster.core.search.service.Minimizer +import org.evomaster.core.search.service.Sampler + +/** + * Guice module wiring the MCP blackbox testing components. + * + * Mirrors [GraphQLBlackBoxModule] but for the MCP problem domain. + * No TestCaseWriter is bound yet (MCP test output not implemented in Phase 4). + */ +class McpBlackBoxModule( + val usingRemoteController: Boolean +) : AbstractModule() { + + override fun configure() { + + bind(object : TypeLiteral>() {}) + .to(McpSampler::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpSampler::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpSampler::class.java) + .asEagerSingleton() + + bind(McpSampler::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpBlackBoxFitness::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpBlackBoxFitness::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(object : TypeLiteral>() {}) + + bind(Archive::class.java) + .to(object : TypeLiteral>() {}) + + bind(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(object : TypeLiteral>() {}) + .asEagerSingleton() + + if (usingRemoteController) { + bind(RemoteController::class.java) + .to(RemoteControllerImplementation::class.java) + .asEagerSingleton() + } + + // TestCaseWriter for MCP is not yet implemented. + // When MCP test output is added, bind TestCaseWriter to an McpTestCaseWriter here. + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt new file mode 100644 index 0000000000..ddb4e26ab0 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt @@ -0,0 +1,11 @@ +package org.evomaster.core.problem.mcp.service + +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.search.service.FitnessFunction + +/** + * Abstract fitness function base for MCP testing. + * Concrete subclasses provide the actual coverage calculation strategy + * (blackbox, whitebox, etc.). + */ +abstract class McpFitness : FitnessFunction() diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt new file mode 100644 index 0000000000..dc7aa41d47 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -0,0 +1,210 @@ +package org.evomaster.core.problem.mcp.service + +import org.evomaster.client.java.controller.api.dto.SutInfoDto +import org.evomaster.core.problem.api.service.ApiWsSampler +import org.evomaster.core.problem.enterprise.EnterpriseActionGroup +import org.evomaster.core.problem.enterprise.SampleType +import org.evomaster.core.problem.mcp.McpAction +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.problem.mcp.McpResourceReadAction +import org.evomaster.core.problem.mcp.McpToolCallAction +import org.evomaster.core.problem.mcp.client.HttpMcpClient +import org.evomaster.core.search.action.ActionComponent +import org.evomaster.core.search.gene.Gene +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.collection.ArrayGene +import org.evomaster.core.search.gene.numeric.IntegerGene +import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.string.StringGene +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import javax.annotation.PostConstruct + +/** + * Sampler for MCP blackbox testing. + * + * On initialization, connects to the MCP server at [config.bbTargetUrl], discovers + * tools and resources, and builds an action cluster. Follows the pattern of GraphQLSampler + * (blackbox init) and RPCSampler (dual cluster + adHoc individuals). + */ +class McpSampler : ApiWsSampler() { + + companion object { + private val log: Logger = LoggerFactory.getLogger(McpSampler::class.java) + } + + private lateinit var mcpClient: HttpMcpClient + + /** Actions for MCP tool calls, keyed by "tool:" */ + private val toolActionCluster: MutableMap = mutableMapOf() + + /** Actions for MCP resource reads, keyed by "resource:" or template key */ + private val resourceActionCluster: MutableMap = mutableMapOf() + + /** Pre-built single-call individuals, drained first in smartSample() */ + private val adHocInitialIndividuals: MutableList = mutableListOf() + + @PostConstruct + fun initialize() { + log.debug("Initializing {}", McpSampler::class.simpleName) + + mcpClient = HttpMcpClient(config.bbTargetUrl) + + actionCluster.clear() + toolActionCluster.clear() + resourceActionCluster.clear() + + // Discover tools + val tools = mcpClient.listTools() + for (tool in tools) { + val inputGene = buildObjectGeneFromSchema("input", tool.inputSchema) + val action = McpToolCallAction( + toolName = tool.name, + inputSchema = inputGene, + description = tool.description + ) + toolActionCluster[action.id] = action + actionCluster[action.id] = action + } + + // Discover static resources + val resources = mcpClient.listResources() + for (resource in resources) { + val uri = StringGene("uri", resource.uri) + val action = McpResourceReadAction(uri = uri, isTemplate = false) + resourceActionCluster[action.id] = action + actionCluster[action.id] = action + } + + // Discover resource templates + val templates = mcpClient.listResourceTemplates() + for (template in templates) { + val uri = StringGene("uri", template.uriTemplate) + val action = McpResourceReadAction(uri = uri, isTemplate = true) + // Use "template:" prefix to avoid collision with static resource keys + val key = "template:${template.uriTemplate}" + resourceActionCluster[key] = action + actionCluster[key] = action + } + + customizeAdHocInitialIndividuals() + + log.debug("Done initializing {} — {} tools, {} resources", + McpSampler::class.simpleName, toolActionCluster.size, resourceActionCluster.size) + } + + // ------------------------------------------------------------------------- + // Sampling + // ------------------------------------------------------------------------- + + override fun sampleAtRandom(): McpIndividual { + val allActions: List = + toolActionCluster.values.toList() + resourceActionCluster.values.toList() + + if (allActions.isEmpty()) { + // Edge case: no capabilities discovered — return empty individual + val ind = McpIndividual(SampleType.RANDOM, mutableListOf()) + ind.doGlobalInitialize(searchGlobalState) + return ind + } + + val n = randomness.nextInt(1, getMaxTestSizeDuringSampler()) + val groups: MutableList = (0 until n).map { + val action = randomness.choose(allActions).copy() as McpAction + action.doInitialize(randomness) + makeGroup(action) + }.toMutableList() + + val ind = McpIndividual(SampleType.RANDOM, groups) + ind.doGlobalInitialize(searchGlobalState) + return ind + } + + override fun smartSample(): McpIndividual { + if (adHocInitialIndividuals.isNotEmpty()) { + return adHocInitialIndividuals.removeAt(adHocInitialIndividuals.size - 1) + } + return sampleAtRandom() + } + + override fun hasSpecialInitForSmartSampler(): Boolean = adHocInitialIndividuals.isNotEmpty() + + override fun initSeededTests(infoDto: SutInfoDto?) { + // Not supported in Phase 3 + } + + // ------------------------------------------------------------------------- + // AdHoc individuals — one per discovered capability + // ------------------------------------------------------------------------- + + private fun customizeAdHocInitialIndividuals() { + adHocInitialIndividuals.clear() + + for ((_, action) in toolActionCluster) { + val copy = action.copy() as McpAction + copy.doInitialize(randomness) + val ind = McpIndividual(SampleType.RANDOM, mutableListOf(makeGroup(copy))) + ind.doGlobalInitialize(searchGlobalState) + adHocInitialIndividuals.add(ind) + } + + for ((_, action) in resourceActionCluster) { + val copy = action.copy() as McpAction + copy.doInitialize(randomness) + val ind = McpIndividual(SampleType.RANDOM, mutableListOf(makeGroup(copy))) + ind.doGlobalInitialize(searchGlobalState) + adHocInitialIndividuals.add(ind) + } + } + + /** + * Wrap a single [McpAction] in an [EnterpriseActionGroup]. + * Uses the single-action convenience constructor. + */ + private fun makeGroup(action: McpAction): EnterpriseActionGroup { + return EnterpriseActionGroup(action) + } + + // ------------------------------------------------------------------------- + // Gene building from JSON Schema + // ------------------------------------------------------------------------- + + /** + * Recursively build a [Gene] from a JSON Schema node. + * + * Supported types: string, integer, number, boolean, object, array. + * Unknown or missing types fall back to [StringGene]. + */ + internal fun buildGeneFromSchema(name: String, schema: Map): Gene { + val type = schema["type"] as? String + + return when (type) { + "string" -> StringGene(name) + "integer", "number" -> IntegerGene(name) + "boolean" -> BooleanGene(name) + "object" -> buildObjectGeneFromSchema(name, schema) + "array" -> ArrayGene(name, StringGene("element")) + else -> StringGene(name) // fallback for unknown/null types + } + } + + /** + * Build an [ObjectGene] from a JSON Schema map. + * If the schema has no "properties" key, returns an empty [ObjectGene]. + */ + @Suppress("UNCHECKED_CAST") + internal fun buildObjectGeneFromSchema(name: String, schema: Map): ObjectGene { + val properties = schema["properties"] as? Map ?: emptyMap() + val fields = properties.entries.map { (propName, propSchema) -> + val propSchemaMap = propSchema as? Map ?: emptyMap() + buildGeneFromSchema(propName, propSchemaMap) + } + return ObjectGene(name, fields) + } + + // ------------------------------------------------------------------------- + // Expose client for fitness function + // ------------------------------------------------------------------------- + + fun getMcpClient(): HttpMcpClient = mcpClient +} diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt new file mode 100644 index 0000000000..bcf4c2dabe --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt @@ -0,0 +1,56 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class McpActionTest { + + @Test + fun `McpToolCallAction getName returns tool colon toolName`() { + val inputGene = ObjectGene("input", emptyList()) + val action = McpToolCallAction("myTool", inputGene) + assertEquals("tool:myTool", action.getName()) + } + + @Test + fun `McpResourceReadAction getName starts with resource colon`() { + val uriGene = StringGene("uri", "http://example.com/resource") + val action = McpResourceReadAction(uriGene) + assertTrue(action.getName().startsWith("resource:")) + } + + @Test + fun `seeTopGenes on McpToolCallAction returns the ObjectGene`() { + val inputGene = ObjectGene("input", emptyList()) + val action = McpToolCallAction("myTool", inputGene) + val topGenes = action.seeTopGenes() + assertEquals(1, topGenes.size) + assertSame(inputGene, topGenes[0]) + } + + @Test + fun `copy on McpToolCallAction produces structurally equal but independent copy`() { + val inputGene = ObjectGene("input", emptyList()) + val action = McpToolCallAction("myTool", inputGene, "a tool description") + val copy = action.copy() as McpToolCallAction + + assertEquals(action.toolName, copy.toolName) + assertEquals(action.description, copy.description) + assertEquals(action.getName(), copy.getName()) + // Must be independent: different gene instances + assertNotSame(action.inputSchema, copy.inputSchema) + } + + @Test + fun `copy on McpResourceReadAction produces structurally equal but independent copy`() { + val uriGene = StringGene("uri", "file:///data/res") + val action = McpResourceReadAction(uriGene, isTemplate = true) + val copy = action.copy() as McpResourceReadAction + + assertEquals(action.isTemplate, copy.isTemplate) + assertNotSame(action.uri, copy.uri) + assertEquals(action.uri.value, copy.uri.value) + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt new file mode 100644 index 0000000000..40ba3fe26d --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt @@ -0,0 +1,78 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.enterprise.EnterpriseActionGroup +import org.evomaster.core.problem.enterprise.SampleType +import org.evomaster.core.search.GroupsOfChildren +import org.evomaster.core.search.action.ActionComponent +import org.evomaster.core.search.gene.ObjectGene +import org.evomaster.core.search.gene.string.StringGene +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class McpIndividualTest { + + private fun buildIndividual(vararg actions: McpAction): McpIndividual { + val wrappedActions: MutableList> = + actions.map { EnterpriseActionGroup(it) }.toMutableList() + @Suppress("UNCHECKED_CAST") + return McpIndividual( + sampleType = SampleType.RANDOM, + allActions = wrappedActions as MutableList + ) + } + + @Test + fun `seeMainExecutableActions returns all actions`() { + val toolAction = McpToolCallAction("myTool", ObjectGene("input", emptyList())) + val resourceAction = McpResourceReadAction(StringGene("uri", "file:///data")) + + val individual = buildIndividual(toolAction, resourceAction) + + val mainActions = individual.seeMainExecutableActions() + assertEquals(2, mainActions.size) + } + + @Test + fun `copyContent returns equal-sized independent copy`() { + val toolAction = McpToolCallAction("myTool", ObjectGene("input", emptyList())) + val resourceAction = McpResourceReadAction(StringGene("uri", "file:///data")) + + val individual = buildIndividual(toolAction, resourceAction) + val copy = individual.copy() as McpIndividual + + assertEquals( + individual.seeMainExecutableActions().size, + copy.seeMainExecutableActions().size + ) + // Must be independent: different action instances + assertNotSame( + individual.seeMainExecutableActions()[0], + copy.seeMainExecutableActions()[0] + ) + } + + @Test + fun `addMcpAction increases action count`() { + val toolAction = McpToolCallAction("myTool", ObjectGene("input", emptyList())) + val individual = buildIndividual(toolAction) + + assertEquals(1, individual.seeMainExecutableActions().size) + + val newAction = McpToolCallAction("anotherTool", ObjectGene("input2", emptyList())) + individual.addMcpAction(action = newAction) + + assertEquals(2, individual.seeMainExecutableActions().size) + } + + @Test + fun `group size is tracked correctly`() { + val toolAction = McpToolCallAction("myTool", ObjectGene("input", emptyList())) + val resourceAction = McpResourceReadAction(StringGene("uri", "file:///data")) + + val individual = buildIndividual(toolAction, resourceAction) + + assertEquals(2, individual.groupsView()!!.sizeOfGroup(GroupsOfChildren.MAIN)) + assertEquals(0, individual.groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_SQL)) + assertEquals(0, individual.groupsView()!!.sizeOfGroup(GroupsOfChildren.INITIALIZATION_MONGO)) + } +} diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt new file mode 100644 index 0000000000..bc3be7d232 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt @@ -0,0 +1,183 @@ +package org.evomaster.core.problem.mcp.client + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.client.WireMock.* +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class HttpMcpClientTest { + + private lateinit var wm: WireMockServer + private lateinit var client: HttpMcpClient + + @BeforeEach + fun setUp() { + wm = WireMockServer(WireMockConfiguration().dynamicPort()) + wm.start() + client = HttpMcpClient("http://localhost:${wm.port()}/mcp") + } + + @AfterEach + fun tearDown() { + wm.stop() + } + + private fun stubPost(responseBody: String) { + wm.stubFor( + WireMock.post(urlEqualTo("/mcp")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(responseBody) + ) + ) + } + + @Test + fun `listTools parses tool definitions correctly`() { + stubPost( + """{"jsonrpc":"2.0","result":{"tools":[{"name":"foo","description":"bar","inputSchema":{}}]},"id":1}""" + ) + + val tools = client.listTools() + + assertEquals(1, tools.size) + assertEquals("foo", tools[0].name) + assertEquals("bar", tools[0].description) + } + + @Test + fun `listTools handles pagination with nextCursor`() { + // First page + wm.stubFor( + WireMock.post(urlEqualTo("/mcp")) + .inScenario("pagination") + .whenScenarioStateIs(com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """{"jsonrpc":"2.0","result":{"tools":[{"name":"tool1","description":"","inputSchema":{}}],"nextCursor":"page2"},"id":1}""" + ) + ) + .willSetStateTo("page2") + ) + // Second page + wm.stubFor( + WireMock.post(urlEqualTo("/mcp")) + .inScenario("pagination") + .whenScenarioStateIs("page2") + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody( + """{"jsonrpc":"2.0","result":{"tools":[{"name":"tool2","description":"","inputSchema":{}}]},"id":2}""" + ) + ) + ) + + val tools = client.listTools() + + assertEquals(2, tools.size) + assertEquals("tool1", tools[0].name) + assertEquals("tool2", tools[1].name) + } + + @Test + fun `callTool with isError false returns success result`() { + stubPost( + """{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"hello"}],"isError":false},"id":1}""" + ) + + val result = client.callTool("foo", mapOf("arg" to "value")) + + assertFalse(result.isError) + assertEquals(1, result.content.size) + assertEquals("text", result.content[0].type) + assertEquals("hello", result.content[0].text) + } + + @Test + fun `callTool with isError true returns error result`() { + stubPost( + """{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"error message"}],"isError":true},"id":1}""" + ) + + val result = client.callTool("foo", emptyMap()) + + assertTrue(result.isError) + assertEquals(1, result.content.size) + assertEquals("error message", result.content[0].text) + } + + @Test + fun `callTool with missing result returns isError true`() { + stubPost( + """{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}""" + ) + + val result = client.callTool("nonexistent", emptyMap()) + + assertTrue(result.isError) + assertTrue(result.content.isEmpty()) + } + + @Test + fun `readResource parses content correctly`() { + stubPost( + """{"jsonrpc":"2.0","result":{"contents":[{"type":"text","text":"resource content","uri":"file:///data/res"}]},"id":1}""" + ) + + val result = client.readResource("file:///data/res") + + assertEquals(1, result.contents.size) + assertEquals("text", result.contents[0].type) + assertEquals("resource content", result.contents[0].text) + assertEquals("file:///data/res", result.contents[0].uri) + } + + @Test + fun `readResource with missing result returns empty contents`() { + stubPost( + """{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}""" + ) + + val result = client.readResource("unknown://uri") + + assertTrue(result.contents.isEmpty()) + } + + @Test + fun `listResources parses resource definitions correctly`() { + stubPost( + """{"jsonrpc":"2.0","result":{"resources":[{"uri":"file:///data","name":"data","description":"A data resource","mimeType":"application/json"}]},"id":1}""" + ) + + val resources = client.listResources() + + assertEquals(1, resources.size) + assertEquals("file:///data", resources[0].uri) + assertEquals("data", resources[0].name) + assertEquals("application/json", resources[0].mimeType) + } + + @Test + fun `listResourceTemplates parses templates correctly`() { + stubPost( + """{"jsonrpc":"2.0","result":{"resourceTemplates":[{"uriTemplate":"file:///{path}","name":"fileTemplate","description":"A file template"}]},"id":1}""" + ) + + val templates = client.listResourceTemplates() + + assertEquals(1, templates.size) + assertEquals("file:///{path}", templates[0].uriTemplate) + assertEquals("fileTemplate", templates[0].name) + } +} From a1b97414369b11f9f2da230108aaf9973c2c81ed Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Sat, 30 May 2026 11:55:37 -0300 Subject: [PATCH 02/19] Wire MCP into EvoMaster entry point - Add ProblemType.MCP (experimental) to EMConfig enum - Add bbTargetUrl validation for MCP black-box mode - Route ProblemType.MCP to McpBlackBoxModule in Main.init() - Add getAlgorithmKeyMcp() supporting RANDOM, MIO, MOSA, WTS, SMARTS - Dispatch MCP algorithm key in Main.run() - Bind NoTestCaseWriter in McpBlackBoxModule EvoMaster can now be invoked with: --blackBox true --problemType MCP --bbTargetUrl --- .../kotlin/org/evomaster/core/EMConfig.kt | 6 +++- .../main/kotlin/org/evomaster/core/Main.kt | 32 +++++++++++++++++++ .../problem/mcp/service/McpBlackBoxModule.kt | 7 ++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt index d1c9d4559e..f6f394b22f 100644 --- a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt +++ b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt @@ -656,6 +656,9 @@ class EMConfig { if (problemType == ProblemType.GRAPHQL && bbTargetUrl.isBlank()) { throw ConfigProblemException("In black-box mode for GraphQL APIs, you must set the bbTargetUrl option") } + if (problemType == ProblemType.MCP && bbTargetUrl.isBlank()) { + throw ConfigProblemException("In black-box mode for MCP servers, you must set the bbTargetUrl option") + } } if (!blackBox && bbExperiments) { @@ -1393,7 +1396,8 @@ class EMConfig { REST(experimental = false), GRAPHQL(experimental = false), RPC(experimental = true), - WEBFRONTEND(experimental = true); + WEBFRONTEND(experimental = true), + MCP(experimental = true); override fun isExperimental() = experimental } diff --git a/core/src/main/kotlin/org/evomaster/core/Main.kt b/core/src/main/kotlin/org/evomaster/core/Main.kt index df9ba10a74..c55094a814 100644 --- a/core/src/main/kotlin/org/evomaster/core/Main.kt +++ b/core/src/main/kotlin/org/evomaster/core/Main.kt @@ -24,6 +24,8 @@ import org.evomaster.core.problem.externalservice.httpws.service.HttpWsExternalS import org.evomaster.core.problem.graphql.GraphQLIndividual import org.evomaster.core.problem.graphql.service.GraphQLBlackBoxModule import org.evomaster.core.problem.graphql.service.GraphQLModule +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.problem.mcp.service.McpBlackBoxModule import org.evomaster.core.problem.rest.data.RestIndividual import org.evomaster.core.problem.rest.service.* import org.evomaster.core.problem.rest.service.module.BlackBoxRestModule @@ -613,6 +615,13 @@ class Main { WebModule() } + EMConfig.ProblemType.MCP -> { + if (!config.blackBox) { + throw IllegalStateException("MCP only supports black-box mode") + } + McpBlackBoxModule(false) + } + //this should never happen, unless we add new type and forget to add it here else -> throw IllegalStateException("Unrecognized problem type: ${config.problemType}") } @@ -820,6 +829,28 @@ class Main { } } + private fun getAlgorithmKeyMcp(config: EMConfig): Key> { + + return when (config.algorithm) { + EMConfig.Algorithm.RANDOM -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.MIO -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.MOSA -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.WTS -> + Key.get(object : TypeLiteral>() {}) + + EMConfig.Algorithm.SMARTS -> + Key.get(object : TypeLiteral>() {}) + + else -> throw IllegalStateException("Unrecognized algorithm ${config.algorithm} for MCP") + } + } + private fun getAlgorithmKeyRest(config: EMConfig): Key> { return when (config.algorithm) { @@ -891,6 +922,7 @@ class Main { EMConfig.ProblemType.GRAPHQL -> getAlgorithmKeyGraphQL(config) EMConfig.ProblemType.RPC -> getAlgorithmKeyRPC(config) EMConfig.ProblemType.WEBFRONTEND -> getAlgorithmKeyWeb(config) + EMConfig.ProblemType.MCP -> getAlgorithmKeyMcp(config) else -> throw IllegalStateException("Unrecognized problem type ${config.problemType}") } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt index 10356a8650..603253be77 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt @@ -2,6 +2,8 @@ package org.evomaster.core.problem.mcp.service import com.google.inject.AbstractModule import com.google.inject.TypeLiteral +import org.evomaster.core.output.service.NoTestCaseWriter +import org.evomaster.core.output.service.TestCaseWriter import org.evomaster.core.problem.enterprise.service.EnterpriseSampler import org.evomaster.core.problem.mcp.McpIndividual import org.evomaster.core.remote.service.RemoteController @@ -76,7 +78,8 @@ class McpBlackBoxModule( .asEagerSingleton() } - // TestCaseWriter for MCP is not yet implemented. - // When MCP test output is added, bind TestCaseWriter to an McpTestCaseWriter here. + bind(TestCaseWriter::class.java) + .to(NoTestCaseWriter::class.java) + .asEagerSingleton() } } From 7f75a39c4553f0975129e4ff82ff02f3c26c82ee Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Sat, 30 May 2026 12:28:46 -0300 Subject: [PATCH 03/19] Wrap McpSampler discovery in SutProblemException Connection failures during @PostConstruct were surfacing as an uncaught InvocationTargetException labelled as an EvoMaster bug. Wrap the listTools() call so a failed connection to the MCP server produces a clean, user-readable SutProblemException instead. --- .../evomaster/core/problem/mcp/service/McpSampler.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt index dc7aa41d47..7a3a9bf495 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -2,6 +2,7 @@ package org.evomaster.core.problem.mcp.service import org.evomaster.client.java.controller.api.dto.SutInfoDto import org.evomaster.core.problem.api.service.ApiWsSampler +import org.evomaster.core.remote.SutProblemException import org.evomaster.core.problem.enterprise.EnterpriseActionGroup import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.problem.mcp.McpAction @@ -55,7 +56,14 @@ class McpSampler : ApiWsSampler() { resourceActionCluster.clear() // Discover tools - val tools = mcpClient.listTools() + val tools = try { + mcpClient.listTools() + } catch (e: Exception) { + throw SutProblemException( + "Failed to connect to MCP server at '${config.bbTargetUrl}'. " + + "Make sure the server is running and the URL is correct. Cause: ${e.message}" + ) + } for (tool in tools) { val inputGene = buildObjectGeneFromSchema("input", tool.inputSchema) val action = McpToolCallAction( From ba9be0a62d83b36f936d8e01d3fe820e6e76f6d6 Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Sat, 30 May 2026 12:37:27 -0300 Subject: [PATCH 04/19] Handle HTTP 4xx from MCP servers that don't implement all endpoints Servers may return 400/404/405 for MCP methods they don't support (e.g., resources/list on a tools-only server). Previously this caused an IOException from HttpURLConnection.inputStream on non-2xx responses, crashing the sampler during initialization. Extracted openConnection() helper and made post() return null on 400/404/405, which callers treat as an empty/error result. --- .../core/problem/mcp/client/HttpMcpClient.kt | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index c6bfff068c..10c4b7365c 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -12,7 +12,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { private fun nextId() = idCounter.getAndIncrement() - private fun post(method: String, params: Map = emptyMap()): Map { + private fun openConnection(method: String, params: Map): Pair { val body = mapper.writeValueAsString( mapOf( "jsonrpc" to "2.0", @@ -21,8 +21,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { "id" to nextId() ) ) - val url = URL(baseUrl) - val conn = url.openConnection() as HttpURLConnection + val conn = URL(baseUrl).openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Accept", "application/json") @@ -30,8 +29,19 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { conn.connectTimeout = 10_000 conn.readTimeout = 30_000 conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + return Pair(conn, body) + } + + /** Send a JSON-RPC request. Throws on connection errors; throws on 5xx. Returns null on 4xx (method not supported). */ + @Suppress("UNCHECKED_CAST") + private fun post(method: String, params: Map = emptyMap()): Map? { + val (conn, _) = openConnection(method, params) + val status = conn.responseCode + if (status == 400 || status == 404 || status == 405) { + // server does not support this MCP method — treat as empty + return null + } val responseBody = conn.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } - @Suppress("UNCHECKED_CAST") return mapper.readValue(responseBody, Map::class.java) as Map } @@ -41,7 +51,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { var cursor: String? = null do { val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() - val response = post("tools/list", params) + val response = post("tools/list", params) ?: break val result = response["result"] as? Map ?: break val items = result["tools"] as? List<*> ?: emptyList() items.filterIsInstance>().forEach { t -> @@ -64,7 +74,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { var cursor: String? = null do { val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() - val response = post("resources/list", params) + val response = post("resources/list", params) ?: break val result = response["result"] as? Map ?: break val items = result["resources"] as? List<*> ?: emptyList() items.filterIsInstance>().forEach { r -> @@ -88,7 +98,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { var cursor: String? = null do { val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() - val response = post("resources/templates/list", params) + val response = post("resources/templates/list", params) ?: break val result = response["result"] as? Map ?: break val items = result["resourceTemplates"] as? List<*> ?: emptyList() items.filterIsInstance>().forEach { t -> @@ -108,6 +118,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { @Suppress("UNCHECKED_CAST") override fun callTool(name: String, arguments: Map): McpToolResult { val response = post("tools/call", mapOf("name" to name, "arguments" to arguments)) + ?: return McpToolResult(isError = true) val result = response["result"] as? Map ?: return McpToolResult(isError = true) val rawContent = result["content"] as? List<*> ?: emptyList() val content = rawContent.filterIsInstance>().map { c -> @@ -127,6 +138,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { @Suppress("UNCHECKED_CAST") override fun readResource(uri: String): McpResourceResult { val response = post("resources/read", mapOf("uri" to uri)) + ?: return McpResourceResult() val result = response["result"] as? Map ?: return McpResourceResult() val rawContents = result["contents"] as? List<*> ?: emptyList() val contents = rawContents.filterIsInstance>().map { c -> From badf32d113dd4e48d629d0abdaed0c74fbd4cb13 Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Mon, 1 Jun 2026 21:53:11 -0300 Subject: [PATCH 05/19] Add missing header --- .../org/evomaster/core/problem/mcp/client/HttpMcpClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 10c4b7365c..27ff99d2ca 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -24,7 +24,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { val conn = URL(baseUrl).openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", "application/json") + conn.setRequestProperty("Accept", "application/json, text/event-stream") conn.doOutput = true conn.connectTimeout = 10_000 conn.readTimeout = 30_000 From cf1c9678ef78e7baa0ccaec5323b2ec3591cabc7 Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Thu, 4 Jun 2026 21:23:26 -0300 Subject: [PATCH 06/19] Add MCP initialization handshake --- .../core/problem/mcp/client/HttpMcpClient.kt | 49 ++++++++++++++++++- .../core/problem/mcp/service/McpSampler.kt | 23 +++++++-- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 27ff99d2ca..a2d75703e6 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -24,7 +24,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { val conn = URL(baseUrl).openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", "application/json, text/event-stream") + conn.setRequestProperty("Accept", "application/json") conn.doOutput = true conn.connectTimeout = 10_000 conn.readTimeout = 30_000 @@ -32,9 +32,56 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { return Pair(conn, body) } + /** + * Perform the MCP initialization handshake (initialize + notifications/initialized). + * Must be called once before any other method. + */ + fun initialize() { + val initResponse = postRaw( + "initialize", + mapOf( + "protocolVersion" to "2024-11-05", + "capabilities" to emptyMap(), + "clientInfo" to mapOf("name" to "EvoMaster", "version" to "1.0.0") + ) + ) + if (initResponse == null) { + throw IllegalStateException("MCP initialize handshake failed") + } + // Send the required follow-up notification (fire-and-forget, response is empty/204) + postNotification("notifications/initialized", emptyMap()) + } + + /** Send a JSON-RPC notification (no response expected). */ + private fun postNotification(method: String, params: Map) { + val body = mapper.writeValueAsString( + mapOf( + "jsonrpc" to "2.0", + "method" to method, + "params" to params + // notifications have no "id" + ) + ) + val conn = URL(baseUrl).openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.setRequestProperty("Accept", "application/json") + conn.doOutput = true + conn.connectTimeout = 10_000 + conn.readTimeout = 10_000 + conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } + // Read (and discard) the response to complete the HTTP exchange + try { conn.inputStream.close() } catch (_: Exception) {} + } + /** Send a JSON-RPC request. Throws on connection errors; throws on 5xx. Returns null on 4xx (method not supported). */ @Suppress("UNCHECKED_CAST") private fun post(method: String, params: Map = emptyMap()): Map? { + return postRaw(method, params) + } + + @Suppress("UNCHECKED_CAST") + private fun postRaw(method: String, params: Map): Map? { val (conn, _) = openConnection(method, params) val status = conn.responseCode if (status == 400 || status == 404 || status == 405) { diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt index 7a3a9bf495..0e5fbbf2db 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -24,7 +24,7 @@ import javax.annotation.PostConstruct /** * Sampler for MCP blackbox testing. * - * On initialization, connects to the MCP server at [config.bbTargetUrl], discovers + * On initialization, connects to the MCP server, discovers * tools and resources, and builds an action cluster. Follows the pattern of GraphQLSampler * (blackbox init) and RPCSampler (dual cluster + adHoc individuals). */ @@ -47,14 +47,26 @@ class McpSampler : ApiWsSampler() { @PostConstruct fun initialize() { - log.debug("Initializing {}", McpSampler::class.simpleName) + val name = McpSampler::class.simpleName + val url = config.bbTargetUrl + log.debug("Initializing {}", name) - mcpClient = HttpMcpClient(config.bbTargetUrl) + + mcpClient = HttpMcpClient(url) actionCluster.clear() toolActionCluster.clear() resourceActionCluster.clear() + // MCP requires initialize handshake before any other call + try { + mcpClient.initialize() + } catch (e: Exception) { + throw SutProblemException( + "Failed to initialize MCP session at '${config.bbTargetUrl}'. Cause: ${e.message}" + ) + } + // Discover tools val tools = try { mcpClient.listTools() @@ -97,8 +109,11 @@ class McpSampler : ApiWsSampler() { customizeAdHocInitialIndividuals() + val toolQuantity = toolActionCluster.size + val resourceQuantity = resourceActionCluster.size + log.debug("Done initializing {} — {} tools, {} resources", - McpSampler::class.simpleName, toolActionCluster.size, resourceActionCluster.size) + name, toolQuantity, resourceQuantity) } // ------------------------------------------------------------------------- From 5aea547c2cbe1f356f472445840db2bc18f8293a Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Thu, 18 Jun 2026 19:10:51 -0300 Subject: [PATCH 07/19] Fix for MCP uriTemplate --- core/src/main/kotlin/org/evomaster/core/EMConfig.kt | 3 ++- .../core/problem/mcp/service/McpBlackBoxFitness.kt | 2 +- .../evomaster/core/problem/mcp/service/McpSampler.kt | 9 +++++---- .../org/evomaster/core/problem/mcp/McpActionTest.kt | 12 ++++++------ .../evomaster/core/problem/mcp/McpIndividualTest.kt | 7 +++---- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt index 4d12f8e077..7faf3bb0f0 100644 --- a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt +++ b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt @@ -1449,7 +1449,8 @@ class EMConfig { REST(experimental = false), GRAPHQL(experimental = false), RPC(experimental = true), - WEBFRONTEND(experimental = true); + WEBFRONTEND(experimental = true), + MCP(experimental = true); override fun isExperimental() = experimental } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt index 01c4a0e5e2..f236dc2d92 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt @@ -67,7 +67,7 @@ class McpBlackBoxFitness : McpFitness() { } is McpResourceReadAction -> { - val resourceResult = client.readResource(action.uri.value) + val resourceResult = client.readResource(action.resolvedUri()) result.setIsError(false) result.stopping = false diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt index 0e5fbbf2db..59fb32f850 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -9,6 +9,7 @@ import org.evomaster.core.problem.mcp.McpAction import org.evomaster.core.problem.mcp.McpIndividual import org.evomaster.core.problem.mcp.McpResourceReadAction import org.evomaster.core.problem.mcp.McpToolCallAction +import org.evomaster.core.problem.mcp.McpUriParam import org.evomaster.core.problem.mcp.client.HttpMcpClient import org.evomaster.core.search.action.ActionComponent import org.evomaster.core.search.gene.Gene @@ -90,8 +91,7 @@ class McpSampler : ApiWsSampler() { // Discover static resources val resources = mcpClient.listResources() for (resource in resources) { - val uri = StringGene("uri", resource.uri) - val action = McpResourceReadAction(uri = uri, isTemplate = false) + val action = McpResourceReadAction(uriTemplate = resource.uri, uriParams = emptyList(), isTemplate = false) resourceActionCluster[action.id] = action actionCluster[action.id] = action } @@ -99,8 +99,9 @@ class McpSampler : ApiWsSampler() { // Discover resource templates val templates = mcpClient.listResourceTemplates() for (template in templates) { - val uri = StringGene("uri", template.uriTemplate) - val action = McpResourceReadAction(uri = uri, isTemplate = true) + val paramNames = Regex("""\{(\w+)}""").findAll(template.uriTemplate).map { it.groupValues[1] }.toList() + val uriParams = paramNames.map { McpUriParam(it, StringGene(it)) } + val action = McpResourceReadAction(uriTemplate = template.uriTemplate, uriParams = uriParams, isTemplate = true) // Use "template:" prefix to avoid collision with static resource keys val key = "template:${template.uriTemplate}" resourceActionCluster[key] = action diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt index bcf4c2dabe..55da86aebd 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt @@ -16,8 +16,7 @@ class McpActionTest { @Test fun `McpResourceReadAction getName starts with resource colon`() { - val uriGene = StringGene("uri", "http://example.com/resource") - val action = McpResourceReadAction(uriGene) + val action = McpResourceReadAction(uriTemplate = "http://example.com/resource", uriParams = emptyList()) assertTrue(action.getName().startsWith("resource:")) } @@ -45,12 +44,13 @@ class McpActionTest { @Test fun `copy on McpResourceReadAction produces structurally equal but independent copy`() { - val uriGene = StringGene("uri", "file:///data/res") - val action = McpResourceReadAction(uriGene, isTemplate = true) + val param = McpUriParam("city", StringGene("city", "paris")) + val action = McpResourceReadAction(uriTemplate = "weather:///{city}/current", uriParams = listOf(param), isTemplate = true) val copy = action.copy() as McpResourceReadAction assertEquals(action.isTemplate, copy.isTemplate) - assertNotSame(action.uri, copy.uri) - assertEquals(action.uri.value, copy.uri.value) + assertEquals(action.uriTemplate, copy.uriTemplate) + assertNotSame(action.uriParams[0], copy.uriParams[0]) + assertEquals(action.resolvedUri(), copy.resolvedUri()) } } diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt index 40ba3fe26d..0adfa8cbee 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt @@ -5,7 +5,6 @@ import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.search.GroupsOfChildren import org.evomaster.core.search.action.ActionComponent import org.evomaster.core.search.gene.ObjectGene -import org.evomaster.core.search.gene.string.StringGene import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -24,7 +23,7 @@ class McpIndividualTest { @Test fun `seeMainExecutableActions returns all actions`() { val toolAction = McpToolCallAction("myTool", ObjectGene("input", emptyList())) - val resourceAction = McpResourceReadAction(StringGene("uri", "file:///data")) + val resourceAction = McpResourceReadAction(uriTemplate = "file:///data", uriParams = emptyList()) val individual = buildIndividual(toolAction, resourceAction) @@ -35,7 +34,7 @@ class McpIndividualTest { @Test fun `copyContent returns equal-sized independent copy`() { val toolAction = McpToolCallAction("myTool", ObjectGene("input", emptyList())) - val resourceAction = McpResourceReadAction(StringGene("uri", "file:///data")) + val resourceAction = McpResourceReadAction(uriTemplate = "file:///data", uriParams = emptyList()) val individual = buildIndividual(toolAction, resourceAction) val copy = individual.copy() as McpIndividual @@ -67,7 +66,7 @@ class McpIndividualTest { @Test fun `group size is tracked correctly`() { val toolAction = McpToolCallAction("myTool", ObjectGene("input", emptyList())) - val resourceAction = McpResourceReadAction(StringGene("uri", "file:///data")) + val resourceAction = McpResourceReadAction(uriTemplate = "file:///data", uriParams = emptyList()) val individual = buildIndividual(toolAction, resourceAction) From 38ef921076dc3e82a831d3d536ec85af6e82495e Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Thu, 18 Jun 2026 21:56:06 -0300 Subject: [PATCH 08/19] store MCP-Session-Id on inititalize --- .../core/problem/mcp/client/HttpMcpClient.kt | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index a2d75703e6..03738f859e 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -10,6 +10,9 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { private val mapper: ObjectMapper = ObjectMapper() private val idCounter = AtomicInteger(1) + // Mcp-Session-Id issued by the server during initialize; must be sent on all subsequent requests + @Volatile private var sessionId: String? = null + private fun nextId() = idCounter.getAndIncrement() private fun openConnection(method: String, params: Map): Pair { @@ -24,7 +27,8 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { val conn = URL(baseUrl).openConnection() as HttpURLConnection conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", "application/json") + conn.setRequestProperty("Accept", "application/json, text/event-stream") + sessionId?.let { conn.setRequestProperty("Mcp-Session-Id", it) } conn.doOutput = true conn.connectTimeout = 10_000 conn.readTimeout = 30_000 @@ -32,12 +36,13 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { return Pair(conn, body) } + /** * Perform the MCP initialization handshake (initialize + notifications/initialized). * Must be called once before any other method. */ fun initialize() { - val initResponse = postRaw( + val (conn, _) = openConnection( "initialize", mapOf( "protocolVersion" to "2024-11-05", @@ -45,10 +50,19 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { "clientInfo" to mapOf("name" to "EvoMaster", "version" to "1.0.0") ) ) - if (initResponse == null) { - throw IllegalStateException("MCP initialize handshake failed") + val status = conn.responseCode + if (status >= 400) { + throw IllegalStateException( + "MCP initialize handshake failed with HTTP $status at '$baseUrl'" + ) } - // Send the required follow-up notification (fire-and-forget, response is empty/204) + // Capture session ID before reading the body + conn.getHeaderField("Mcp-Session-Id")?.let { sessionId = it } + val responseBody = conn.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + if (responseBody.isBlank()) { + throw IllegalStateException("MCP initialize handshake returned empty body") + } + // Send the required follow-up notification (fire-and-forget) postNotification("notifications/initialized", emptyMap()) } @@ -66,6 +80,7 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { conn.requestMethod = "POST" conn.setRequestProperty("Content-Type", "application/json") conn.setRequestProperty("Accept", "application/json") + sessionId?.let { conn.setRequestProperty("Mcp-Session-Id", it) } conn.doOutput = true conn.connectTimeout = 10_000 conn.readTimeout = 10_000 @@ -74,18 +89,12 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { try { conn.inputStream.close() } catch (_: Exception) {} } - /** Send a JSON-RPC request. Throws on connection errors; throws on 5xx. Returns null on 4xx (method not supported). */ + /** Send a JSON-RPC request. Returns null on 4xx (method not supported). */ @Suppress("UNCHECKED_CAST") private fun post(method: String, params: Map = emptyMap()): Map? { - return postRaw(method, params) - } - - @Suppress("UNCHECKED_CAST") - private fun postRaw(method: String, params: Map): Map? { val (conn, _) = openConnection(method, params) val status = conn.responseCode if (status == 400 || status == 404 || status == 405) { - // server does not support this MCP method — treat as empty return null } val responseBody = conn.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } From 56c30980ffa2838636982976750ec4e8a323c294 Mon Sep 17 00:00:00 2001 From: Guido Rodriguez Celma <50532651+guidorc@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:08:09 -0300 Subject: [PATCH 09/19] Setting up MCP Problem Type Skeleton (#3) * Setting up MCP Problem Type Skeleton * remove db related size from MCP individual * Fix for resource read action id Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * feedback * adjust resource read action parameters --------- Co-authored-by: guido-rodriguez_sfemu Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../kotlin/org/evomaster/core/EMConfig.kt | 6 +- .../main/kotlin/org/evomaster/core/Main.kt | 4 + .../evomaster/core/problem/mcp/McpAction.kt | 18 +++++ .../core/problem/mcp/McpCallResult.kt | 22 ++++++ .../core/problem/mcp/McpIndividual.kt | 45 +++++++++++ .../core/problem/mcp/McpInputParam.kt | 8 ++ .../core/problem/mcp/McpResourceReadAction.kt | 33 ++++++++ .../core/problem/mcp/McpToolCallAction.kt | 17 ++++ .../evomaster/core/problem/mcp/McpUriParam.kt | 8 ++ .../core/problem/mcp/client/HttpMcpClient.kt | 28 +++++++ .../core/problem/mcp/client/McpClient.kt | 9 +++ .../core/problem/mcp/client/McpDTOs.kt | 36 +++++++++ .../problem/mcp/service/McpBlackBoxFitness.kt | 17 ++++ .../problem/mcp/service/McpBlackBoxModule.kt | 79 +++++++++++++++++++ .../core/problem/mcp/service/McpFitness.kt | 6 ++ .../core/problem/mcp/service/McpSampler.kt | 36 +++++++++ 16 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt diff --git a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt index e873d22342..7faf3bb0f0 100644 --- a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt +++ b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt @@ -711,6 +711,9 @@ class EMConfig { if (problemType == ProblemType.GRAPHQL && bbTargetUrl.isBlank()) { throw ConfigProblemException("In black-box mode for GraphQL APIs, you must set the bbTargetUrl option") } + if (problemType == ProblemType.MCP && base.isBlank()) { + throw ConfigProblemException("In black-box mode for MCP servers, you must set the bbTargetUrl option") + } } if (!blackBox && bbExperiments) { @@ -1446,7 +1449,8 @@ class EMConfig { REST(experimental = false), GRAPHQL(experimental = false), RPC(experimental = true), - WEBFRONTEND(experimental = true); + WEBFRONTEND(experimental = true), + MCP(experimental = true); override fun isExperimental() = experimental } diff --git a/core/src/main/kotlin/org/evomaster/core/Main.kt b/core/src/main/kotlin/org/evomaster/core/Main.kt index 506955bc26..effe64074a 100644 --- a/core/src/main/kotlin/org/evomaster/core/Main.kt +++ b/core/src/main/kotlin/org/evomaster/core/Main.kt @@ -614,6 +614,10 @@ class Main { WebModule() } + EMConfig.ProblemType.MCP -> { + throw IllegalStateException("MCP server analysis is not yet supported") + } + //this should never happen, unless we add new type and forget to add it here else -> throw IllegalStateException("Unrecognized problem type: ${config.problemType}") } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt new file mode 100644 index 0000000000..8527e8f5e5 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt @@ -0,0 +1,18 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.search.action.MainAction +import org.evomaster.core.search.gene.Gene + +abstract class McpAction( + val id: String, + parameters: MutableList +) : MainAction(false, parameters) { + + val actionParameters: List + get() = children as List + + override fun getName(): String = id + + override fun seeTopGenes(): List = actionParameters.flatMap { it.seeGenes() } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt new file mode 100644 index 0000000000..60327de838 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt @@ -0,0 +1,22 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.search.action.ActionResult + +class McpCallResult : ActionResult { + + companion object { + const val IS_ERROR = "IS_ERROR" + } + + constructor(sourceLocalId: String) : super(sourceLocalId) + + private constructor(other: McpCallResult) : super(other) + + override fun copy(): McpCallResult = McpCallResult(this) + + fun setIsError(isError: Boolean) { + addResultValue(IS_ERROR, isError.toString()) + } + + fun getIsError(): Boolean = getResultValue(IS_ERROR)?.toBoolean() ?: false +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt new file mode 100644 index 0000000000..740f77c9ec --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt @@ -0,0 +1,45 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.ApiWsIndividual +import org.evomaster.core.problem.enterprise.EnterpriseChildTypeVerifier +import org.evomaster.core.problem.enterprise.SampleType +import org.evomaster.core.search.GroupsOfChildren +import org.evomaster.core.search.Individual +import org.evomaster.core.search.StructuralElement +import org.evomaster.core.search.action.ActionComponent + +class McpIndividual( + sampleType: SampleType, + allActions: MutableList, + mainSize: Int = allActions.size, + sqlSize: Int = 0, + mongoSize: Int = 0, + groups: GroupsOfChildren = + getEnterpriseTopGroups(allActions, mainSize, sqlSize, mongoSize, 0, 0, 0, 0) +) : ApiWsIndividual( + sampleType = sampleType, + children = allActions, + childTypeVerifier = EnterpriseChildTypeVerifier(McpAction::class.java), + groups = groups +) { + + override fun copyContent(): Individual { + return McpIndividual( + sampleType, + children.map { it.copy() }.toMutableList() as MutableList, + mainSize = groupsView()!!.sizeOfGroup(GroupsOfChildren.MAIN) + ) + } + + fun addMcpAction(relativePosition: Int = -1, action: McpAction) { + addMainActionInEmptyEnterpriseGroup(relativePosition, action) + } + + fun removeMcpActionAt(relativePosition: Int) { + removeMainActionGroupAt(relativePosition) + } + + override fun seeMainExecutableActions(): List { + return super.seeMainExecutableActions() as List + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt new file mode 100644 index 0000000000..035ab3d118 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt @@ -0,0 +1,8 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.search.gene.Gene + +class McpInputParam(name: String, gene: Gene) : Param(name, gene) { + override fun copyContent(): Param = McpInputParam(name, gene.copy()) +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt new file mode 100644 index 0000000000..1ec9c4246b --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt @@ -0,0 +1,33 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.search.action.Action + +/** + * Action to read an MCP resource. + * For direct resources (isTemplate=false), the URI is fixed and no genes are mutable. + * For template resources (isTemplate=true), one StringGene per URI template variable + * (e.g. {city} in weather:///{city}/current) is created and fuzzed independently; + * call resolvedUri() to interpolate current gene values into the template. + */ +class McpResourceReadAction( + val uriTemplate: String, + val uriParams: List, + val isTemplate: Boolean = false +) : McpAction( + id = "resource", + parameters = uriParams.toMutableList() +) { + override fun getName(): String = "resource:$uriTemplate" + + fun resolvedUri(): String { + if (!isTemplate) return uriTemplate + var result = uriTemplate + uriParams.forEach { param -> + result = result.replace("{${param.name}}", param.primaryGene().getValueAsRawString()) + } + return result + } + + override fun copyContent(): Action = + McpResourceReadAction(uriTemplate, uriParams.map { it.copy() as McpUriParam }, isTemplate) +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt new file mode 100644 index 0000000000..bd4ebdda1a --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt @@ -0,0 +1,17 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.search.action.Action +import org.evomaster.core.search.gene.ObjectGene + +class McpToolCallAction( + val toolName: String, + val inputSchema: ObjectGene, + val description: String = "" +) : McpAction( + id = "tool:$toolName", + parameters = mutableListOf(McpInputParam("input", inputSchema)) +) { + override fun copyContent(): Action { + return McpToolCallAction(toolName, inputSchema.copy() as ObjectGene, description) + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt new file mode 100644 index 0000000000..46d9bbdcb7 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt @@ -0,0 +1,8 @@ +package org.evomaster.core.problem.mcp + +import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.search.gene.Gene + +class McpUriParam(name: String, gene: Gene) : Param(name, gene) { + override fun copyContent(): Param = McpUriParam(name, gene.copy()) +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt new file mode 100644 index 0000000000..f3b6a801a4 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -0,0 +1,28 @@ +package org.evomaster.core.problem.mcp.client + +class HttpMcpClient(private val baseUrl: String) : McpClient { + + fun initialize() { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun listTools(): List { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun listResources(): List { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun listResourceTemplates(): List { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun callTool(name: String, arguments: Map): McpToolResult { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun readResource(uri: String): McpResourceResult { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt new file mode 100644 index 0000000000..8f0cc0cda7 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt @@ -0,0 +1,9 @@ +package org.evomaster.core.problem.mcp.client + +interface McpClient { + fun listTools(): List + fun listResources(): List + fun listResourceTemplates(): List + fun callTool(name: String, arguments: Map): McpToolResult + fun readResource(uri: String): McpResourceResult +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt new file mode 100644 index 0000000000..5fea8e0f36 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt @@ -0,0 +1,36 @@ +package org.evomaster.core.problem.mcp.client + +data class McpToolDefinition( + val name: String, + val description: String = "", + val inputSchema: Map = emptyMap() +) + +data class McpResourceDefinition( + val uri: String, + val name: String = "", + val description: String = "", + val mimeType: String? = null +) + +data class McpResourceTemplate( + val uriTemplate: String, + val name: String = "", + val description: String = "" +) + +data class McpToolResult( + val content: List = emptyList(), + val isError: Boolean = false +) + +data class McpResourceResult( + val contents: List = emptyList() +) + +data class McpContent( + val type: String, + val text: String? = null, + val uri: String? = null, + val mimeType: String? = null +) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt new file mode 100644 index 0000000000..fe65b84d33 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxFitness.kt @@ -0,0 +1,17 @@ +package org.evomaster.core.problem.mcp.service + +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.search.EvaluatedIndividual + +class McpBlackBoxFitness : McpFitness() { + + override fun doCalculateCoverage( + individual: McpIndividual, + targets: Set, + allTargets: Boolean, + fullyCovered: Boolean, + descriptiveIds: Boolean, + ): EvaluatedIndividual? { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt new file mode 100644 index 0000000000..be0fbc4310 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpBlackBoxModule.kt @@ -0,0 +1,79 @@ +package org.evomaster.core.problem.mcp.service + +import com.google.inject.AbstractModule +import com.google.inject.TypeLiteral +import org.evomaster.core.output.service.NoTestCaseWriter +import org.evomaster.core.output.service.TestCaseWriter +import org.evomaster.core.problem.enterprise.service.EnterpriseSampler +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.remote.service.RemoteController +import org.evomaster.core.remote.service.RemoteControllerImplementation +import org.evomaster.core.search.service.Archive +import org.evomaster.core.search.service.FitnessFunction +import org.evomaster.core.search.service.FlakinessDetector +import org.evomaster.core.search.service.Minimizer +import org.evomaster.core.search.service.Sampler + +class McpBlackBoxModule( + val usingRemoteController: Boolean +) : AbstractModule() { + + override fun configure() { + + bind(object : TypeLiteral>() {}) + .to(McpSampler::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpSampler::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpSampler::class.java) + .asEagerSingleton() + + bind(McpSampler::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpBlackBoxFitness::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(McpBlackBoxFitness::class.java) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(object : TypeLiteral>() {}) + + bind(Archive::class.java) + .to(object : TypeLiteral>() {}) + + bind(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .asEagerSingleton() + + bind(object : TypeLiteral>() {}) + .to(object : TypeLiteral>() {}) + .asEagerSingleton() + + if (usingRemoteController) { + bind(RemoteController::class.java) + .to(RemoteControllerImplementation::class.java) + .asEagerSingleton() + } + + bind(TestCaseWriter::class.java) + .to(NoTestCaseWriter::class.java) + .asEagerSingleton() + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt new file mode 100644 index 0000000000..dd09fa7764 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpFitness.kt @@ -0,0 +1,6 @@ +package org.evomaster.core.problem.mcp.service + +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.search.service.FitnessFunction + +abstract class McpFitness : FitnessFunction() diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt new file mode 100644 index 0000000000..e4b5e27c3a --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -0,0 +1,36 @@ +package org.evomaster.core.problem.mcp.service + +import org.evomaster.client.java.controller.api.dto.SutInfoDto +import org.evomaster.core.problem.api.service.ApiWsSampler +import org.evomaster.core.problem.mcp.McpIndividual +import org.evomaster.core.problem.mcp.client.HttpMcpClient +import javax.annotation.PostConstruct + +class McpSampler : ApiWsSampler() { + + private lateinit var mcpClient: HttpMcpClient + + @PostConstruct + fun initialize() { + mcpClient = HttpMcpClient(config.base) + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun sampleAtRandom(): McpIndividual { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun smartSample(): McpIndividual { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun hasSpecialInitForSmartSampler(): Boolean { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + override fun initSeededTests(infoDto: SutInfoDto?) { + throw UnsupportedOperationException("MCP server analysis is not yet supported") + } + + fun getMcpClient(): HttpMcpClient = mcpClient +} From 2b0e60469cfa54a45e358fc7b049cd4a8a8d2842 Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Thu, 18 Jun 2026 23:02:30 -0300 Subject: [PATCH 10/19] Adding JavaDocs --- .../org/evomaster/core/problem/mcp/McpAction.kt | 9 +++++++++ .../org/evomaster/core/problem/mcp/McpCallResult.kt | 6 ++++++ .../org/evomaster/core/problem/mcp/McpIndividual.kt | 10 ++++++++++ .../org/evomaster/core/problem/mcp/McpInputParam.kt | 1 + .../core/problem/mcp/McpResourceReadAction.kt | 10 +++++----- .../evomaster/core/problem/mcp/McpToolCallAction.kt | 11 ++++++++--- .../org/evomaster/core/problem/mcp/McpUriParam.kt | 1 + .../core/problem/mcp/client/HttpMcpClient.kt | 12 +++++++++++- .../evomaster/core/problem/mcp/client/McpClient.kt | 8 ++++++++ .../org/evomaster/core/problem/mcp/client/McpDTOs.kt | 6 ++++++ .../evomaster/core/problem/mcp/service/McpSampler.kt | 11 ++++++----- .../org/evomaster/core/problem/mcp/McpActionTest.kt | 3 +-- 12 files changed, 72 insertions(+), 16 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt index 8527e8f5e5..a9a10b1026 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpAction.kt @@ -4,6 +4,15 @@ import org.evomaster.core.problem.api.param.Param import org.evomaster.core.search.action.MainAction import org.evomaster.core.search.gene.Gene +/** + * Base class for all actions that can be executed against an MCP server. + * + * An action can be either a tool call ([McpToolCallAction]) or a resource read ([McpResourceReadAction]). + * + * @param id stable identifier used as the key in the action cluster + * and as the coverage target name (e.g. "tool:myTool", "resource:file:///data") + * @param parameters the mutable action's inputs + */ abstract class McpAction( val id: String, parameters: MutableList diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt index dae50b2449..1588e55805 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpCallResult.kt @@ -2,6 +2,12 @@ package org.evomaster.core.problem.mcp import org.evomaster.core.search.action.ActionResult +/** + * Stores the outcome of executing a single [McpAction] during fitness evaluation. + * + * A result is considered an error when the MCP server sets `isError: true` in the tool-call + * response, or when the fitness function catches an exception from the server. + */ class McpCallResult : ActionResult { companion object { diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt index 55d1562260..c59d230882 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt @@ -9,6 +9,16 @@ import org.evomaster.core.search.Individual import org.evomaster.core.search.StructuralElement import org.evomaster.core.search.action.ActionComponent +/** + * An individual (test case) for MCP blackbox testing. + * + * Represents a sequence of [McpAction]s — tool calls and resource reads — to be executed + * against an MCP server in order. Each action is wrapped in an [EnterpriseActionGroup] and + * stored in the MAIN group. + * + * The search engine samples, mutates, and evaluates instances of this class via + * [McpSampler] and [McpBlackBoxFitness] respectively. + */ class McpIndividual( sampleType: SampleType, allActions: MutableList, diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt index 035ab3d118..a28cc2eb1f 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpInputParam.kt @@ -3,6 +3,7 @@ package org.evomaster.core.problem.mcp import org.evomaster.core.problem.api.param.Param import org.evomaster.core.search.gene.Gene +/** Parameter that carries the payload of a [McpToolCallAction]. */ class McpInputParam(name: String, gene: Gene) : Param(name, gene) { override fun copyContent(): Param = McpInputParam(name, gene.copy()) } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt index 1ec9c4246b..960e1e789c 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpResourceReadAction.kt @@ -3,11 +3,11 @@ package org.evomaster.core.problem.mcp import org.evomaster.core.search.action.Action /** - * Action to read an MCP resource. - * For direct resources (isTemplate=false), the URI is fixed and no genes are mutable. - * For template resources (isTemplate=true), one StringGene per URI template variable - * (e.g. {city} in weather:///{city}/current) is created and fuzzed independently; - * call resolvedUri() to interpolate current gene values into the template. + * Action that reads a resource from an MCP server via the `resources/read` JSON-RPC method. + * + * @param uriTemplate the resource URI or URI template string (e.g. `file:///data`, `weather:///{city}/current`) + * @param uriParams one [McpUriParam] per template variable; empty for direct resources + * @param isTemplate whether this action represents a URI template or a fixed URI */ class McpResourceReadAction( val uriTemplate: String, diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt index bd4ebdda1a..481a483029 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpToolCallAction.kt @@ -3,15 +3,20 @@ package org.evomaster.core.problem.mcp import org.evomaster.core.search.action.Action import org.evomaster.core.search.gene.ObjectGene +/** + * Action that invokes a named tool on an MCP server via the `tools/call` JSON-RPC method. + * + * @param toolName the name of the MCP tool to call + * @param inputSchema an [ObjectGene] which fields represent the tool's input arguments + */ class McpToolCallAction( val toolName: String, - val inputSchema: ObjectGene, - val description: String = "" + val inputSchema: ObjectGene ) : McpAction( id = "tool:$toolName", parameters = mutableListOf(McpInputParam("input", inputSchema)) ) { override fun copyContent(): Action { - return McpToolCallAction(toolName, inputSchema.copy() as ObjectGene, description) + return McpToolCallAction(toolName, inputSchema.copy() as ObjectGene) } } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt index 46d9bbdcb7..da8df0a19a 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpUriParam.kt @@ -3,6 +3,7 @@ package org.evomaster.core.problem.mcp import org.evomaster.core.problem.api.param.Param import org.evomaster.core.search.gene.Gene +/** Parameter representing a single URI template variable in a [McpResourceReadAction]. */ class McpUriParam(name: String, gene: Gene) : Param(name, gene) { override fun copyContent(): Param = McpUriParam(name, gene.copy()) } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 03738f859e..5c94d9ca12 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -5,12 +5,22 @@ import java.net.HttpURLConnection import java.net.URL import java.util.concurrent.atomic.AtomicInteger +/** + * [McpClient] implementation that uses the Streamable HTTP transport. + * + * All MCP messages are sent as HTTP POST requests to [baseUrl] using JSON-RPC 2.0. + * + * **Session lifecycle**: [initialize] must be invoked once before any other method. It performs + * the two-step MCP handshake (`initialize` request + `notifications/initialized` notification) + * and captures the `Mcp-Session-Id` header returned by the server. + * + * @param baseUrl the full URL of the MCP endpoint. + */ class HttpMcpClient(private val baseUrl: String) : McpClient { private val mapper: ObjectMapper = ObjectMapper() private val idCounter = AtomicInteger(1) - // Mcp-Session-Id issued by the server during initialize; must be sent on all subsequent requests @Volatile private var sessionId: String? = null private fun nextId() = idCounter.getAndIncrement() diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt index 8f0cc0cda7..365a2b67e3 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpClient.kt @@ -1,5 +1,13 @@ package org.evomaster.core.problem.mcp.client +/** + * Abstraction over the MCP (Model Context Protocol) JSON-RPC transport. + * + * Defines the operations available for interaction with an MCP server: + * capability discovery ([listTools], [listResources], [listResourceTemplates]) and + * invocation ([callTool], [readResource]). + * + */ interface McpClient { fun listTools(): List fun listResources(): List diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt index 5fea8e0f36..e069c17654 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt @@ -1,11 +1,13 @@ package org.evomaster.core.problem.mcp.client +/** Tool definition as returned by the MCP `tools/list` response. */ data class McpToolDefinition( val name: String, val description: String = "", val inputSchema: Map = emptyMap() ) +/** Static resource as returned by the MCP `resources/list` response. */ data class McpResourceDefinition( val uri: String, val name: String = "", @@ -13,21 +15,25 @@ data class McpResourceDefinition( val mimeType: String? = null ) +/** URI-template resource as returned by the MCP `resources/templates/list` response. */ data class McpResourceTemplate( val uriTemplate: String, val name: String = "", val description: String = "" ) +/** Result of a `tools/call` invocation, as defined by the MCP specification. */ data class McpToolResult( val content: List = emptyList(), val isError: Boolean = false ) +/** Result of a `resources/read` invocation, as defined by the MCP specification. */ data class McpResourceResult( val contents: List = emptyList() ) +/** Content item within a tool or resource response. */ data class McpContent( val type: String, val text: String? = null, diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt index 59fb32f850..fa2345406b 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -25,9 +25,11 @@ import javax.annotation.PostConstruct /** * Sampler for MCP blackbox testing. * - * On initialization, connects to the MCP server, discovers - * tools and resources, and builds an action cluster. Follows the pattern of GraphQLSampler - * (blackbox init) and RPCSampler (dual cluster + adHoc individuals). + * On initialization ([initialize]), connects to the MCP server, performs the MCP handshake + * and discovers capabilities: + * - **Tools** (`tools/list`) → one [McpToolCallAction] per tool. + * - **Static resources** (`resources/list`) → one [McpResourceReadAction] per URI. + * - **Template resources** (`resources/templates/list`) → one [McpResourceReadAction] per template. */ class McpSampler : ApiWsSampler() { @@ -81,8 +83,7 @@ class McpSampler : ApiWsSampler() { val inputGene = buildObjectGeneFromSchema("input", tool.inputSchema) val action = McpToolCallAction( toolName = tool.name, - inputSchema = inputGene, - description = tool.description + inputSchema = inputGene ) toolActionCluster[action.id] = action actionCluster[action.id] = action diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt index 55da86aebd..a4e488411a 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpActionTest.kt @@ -32,11 +32,10 @@ class McpActionTest { @Test fun `copy on McpToolCallAction produces structurally equal but independent copy`() { val inputGene = ObjectGene("input", emptyList()) - val action = McpToolCallAction("myTool", inputGene, "a tool description") + val action = McpToolCallAction("myTool", inputGene) val copy = action.copy() as McpToolCallAction assertEquals(action.toolName, copy.toolName) - assertEquals(action.description, copy.description) assertEquals(action.getName(), copy.getName()) // Must be independent: different gene instances assertNotSame(action.inputSchema, copy.inputSchema) From 11df7b13328147e1b016cc117d6d8d50b1104589 Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Thu, 25 Jun 2026 19:48:28 -0300 Subject: [PATCH 11/19] remove supress unchecked cast annotations --- .../org/evomaster/core/problem/mcp/client/HttpMcpClient.kt | 6 ------ .../org/evomaster/core/problem/mcp/service/McpSampler.kt | 1 - .../org/evomaster/core/problem/mcp/McpIndividualTest.kt | 1 - 3 files changed, 8 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 5c94d9ca12..0d5ae51166 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -100,7 +100,6 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { } /** Send a JSON-RPC request. Returns null on 4xx (method not supported). */ - @Suppress("UNCHECKED_CAST") private fun post(method: String, params: Map = emptyMap()): Map? { val (conn, _) = openConnection(method, params) val status = conn.responseCode @@ -111,7 +110,6 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { return mapper.readValue(responseBody, Map::class.java) as Map } - @Suppress("UNCHECKED_CAST") override fun listTools(): List { val tools = mutableListOf() var cursor: String? = null @@ -134,7 +132,6 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { return tools } - @Suppress("UNCHECKED_CAST") override fun listResources(): List { val resources = mutableListOf() var cursor: String? = null @@ -158,7 +155,6 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { return resources } - @Suppress("UNCHECKED_CAST") override fun listResourceTemplates(): List { val templates = mutableListOf() var cursor: String? = null @@ -181,7 +177,6 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { return templates } - @Suppress("UNCHECKED_CAST") override fun callTool(name: String, arguments: Map): McpToolResult { val response = post("tools/call", mapOf("name" to name, "arguments" to arguments)) ?: return McpToolResult(isError = true) @@ -201,7 +196,6 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { ) } - @Suppress("UNCHECKED_CAST") override fun readResource(uri: String): McpResourceResult { val response = post("resources/read", mapOf("uri" to uri)) ?: return McpResourceResult() diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt index fa2345406b..90e171cfec 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -217,7 +217,6 @@ class McpSampler : ApiWsSampler() { * Build an [ObjectGene] from a JSON Schema map. * If the schema has no "properties" key, returns an empty [ObjectGene]. */ - @Suppress("UNCHECKED_CAST") internal fun buildObjectGeneFromSchema(name: String, schema: Map): ObjectGene { val properties = schema["properties"] as? Map ?: emptyMap() val fields = properties.entries.map { (propName, propSchema) -> diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt index 0adfa8cbee..8a25dd3d03 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/McpIndividualTest.kt @@ -13,7 +13,6 @@ class McpIndividualTest { private fun buildIndividual(vararg actions: McpAction): McpIndividual { val wrappedActions: MutableList> = actions.map { EnterpriseActionGroup(it) }.toMutableList() - @Suppress("UNCHECKED_CAST") return McpIndividual( sampleType = SampleType.RANDOM, allActions = wrappedActions as MutableList From d240e23126c60d6ecc58c99c39334d6dfde78dff Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Sun, 12 Jul 2026 23:02:00 -0300 Subject: [PATCH 12/19] Adopt Jersey client --- .../core/problem/mcp/client/HttpMcpClient.kt | 89 ++++++++----------- 1 file changed, 39 insertions(+), 50 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 0d5ae51166..92e0c7e10a 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -1,74 +1,78 @@ package org.evomaster.core.problem.mcp.client import com.fasterxml.jackson.databind.ObjectMapper -import java.net.HttpURLConnection -import java.net.URL +import org.evomaster.core.remote.HttpClientFactory import java.util.concurrent.atomic.AtomicInteger +import javax.ws.rs.client.Client +import javax.ws.rs.client.Entity +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response /** * [McpClient] implementation that uses the Streamable HTTP transport. * - * All MCP messages are sent as HTTP POST requests to [baseUrl] using JSON-RPC 2.0. + * All MCP messages are sent as HTTP POST requests to [baseUrl] using JSON-RPC 2.0, via the + * Jersey client from [HttpClientFactory]. * * **Session lifecycle**: [initialize] must be invoked once before any other method. It performs * the two-step MCP handshake (`initialize` request + `notifications/initialized` notification) * and captures the `Mcp-Session-Id` header returned by the server. * * @param baseUrl the full URL of the MCP endpoint. + * @param readTimeoutMs read timeout (in milliseconds) for the underlying Jersey client. */ -class HttpMcpClient(private val baseUrl: String) : McpClient { +class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : McpClient { private val mapper: ObjectMapper = ObjectMapper() private val idCounter = AtomicInteger(1) + private val client: Client = HttpClientFactory.createTrustingJerseyClient(true, readTimeoutMs) + @Volatile private var sessionId: String? = null private fun nextId() = idCounter.getAndIncrement() - private fun openConnection(method: String, params: Map): Pair { - val body = mapper.writeValueAsString( - mapOf( - "jsonrpc" to "2.0", - "method" to method, - "params" to params, - "id" to nextId() - ) + /** Send a JSON-RPC message. [id] is omitted for notifications. */ + private fun sendJsonRpc(method: String, params: Map, id: Int?, acceptEventStream: Boolean): Response { + val payload = mutableMapOf( + "jsonrpc" to "2.0", + "method" to method, + "params" to params ) - val conn = URL(baseUrl).openConnection() as HttpURLConnection - conn.requestMethod = "POST" - conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", "application/json, text/event-stream") - sessionId?.let { conn.setRequestProperty("Mcp-Session-Id", it) } - conn.doOutput = true - conn.connectTimeout = 10_000 - conn.readTimeout = 30_000 - conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } - return Pair(conn, body) - } + id?.let { payload["id"] = it } + val body = mapper.writeValueAsString(payload) + + val acceptTypes = if (acceptEventStream) arrayOf("application/json", "text/event-stream") else arrayOf("application/json") + var builder = client.target(baseUrl).request(*acceptTypes) + sessionId?.let { builder = builder.header("Mcp-Session-Id", it) } + return builder.buildPost(Entity.entity(body, MediaType.APPLICATION_JSON_TYPE)).invoke() + } /** * Perform the MCP initialization handshake (initialize + notifications/initialized). * Must be called once before any other method. */ fun initialize() { - val (conn, _) = openConnection( + val response = sendJsonRpc( "initialize", mapOf( "protocolVersion" to "2024-11-05", "capabilities" to emptyMap(), "clientInfo" to mapOf("name" to "EvoMaster", "version" to "1.0.0") - ) + ), + nextId(), + acceptEventStream = true ) - val status = conn.responseCode + val status = response.status if (status >= 400) { throw IllegalStateException( "MCP initialize handshake failed with HTTP $status at '$baseUrl'" ) } // Capture session ID before reading the body - conn.getHeaderField("Mcp-Session-Id")?.let { sessionId = it } - val responseBody = conn.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + response.getHeaderString("Mcp-Session-Id")?.let { sessionId = it } + val responseBody = response.readEntity(String::class.java) if (responseBody.isBlank()) { throw IllegalStateException("MCP initialize handshake returned empty body") } @@ -78,35 +82,20 @@ class HttpMcpClient(private val baseUrl: String) : McpClient { /** Send a JSON-RPC notification (no response expected). */ private fun postNotification(method: String, params: Map) { - val body = mapper.writeValueAsString( - mapOf( - "jsonrpc" to "2.0", - "method" to method, - "params" to params - // notifications have no "id" - ) - ) - val conn = URL(baseUrl).openConnection() as HttpURLConnection - conn.requestMethod = "POST" - conn.setRequestProperty("Content-Type", "application/json") - conn.setRequestProperty("Accept", "application/json") - sessionId?.let { conn.setRequestProperty("Mcp-Session-Id", it) } - conn.doOutput = true - conn.connectTimeout = 10_000 - conn.readTimeout = 10_000 - conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) } - // Read (and discard) the response to complete the HTTP exchange - try { conn.inputStream.close() } catch (_: Exception) {} + val response = sendJsonRpc(method, params, id = null, acceptEventStream = false) + // Discard the response to complete the HTTP exchange and release the connection + try { response.close() } catch (_: Exception) {} } /** Send a JSON-RPC request. Returns null on 4xx (method not supported). */ private fun post(method: String, params: Map = emptyMap()): Map? { - val (conn, _) = openConnection(method, params) - val status = conn.responseCode + val response = sendJsonRpc(method, params, nextId(), acceptEventStream = true) + val status = response.status if (status == 400 || status == 404 || status == 405) { + response.close() return null } - val responseBody = conn.inputStream.use { it.readBytes().toString(Charsets.UTF_8) } + val responseBody = response.readEntity(String::class.java) return mapper.readValue(responseBody, Map::class.java) as Map } From c501ad194d8c84f24c8f2f3fba6ae7f53efd496d Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Sun, 12 Jul 2026 23:09:59 -0300 Subject: [PATCH 13/19] Extract constants into shared file --- .../evomaster/core/problem/mcp/McpConst.kt | 20 +++++++++++++++++++ .../core/problem/mcp/client/HttpMcpClient.kt | 9 +++++---- 2 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 core/src/main/kotlin/org/evomaster/core/problem/mcp/McpConst.kt diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpConst.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpConst.kt new file mode 100644 index 0000000000..f386f1ec03 --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpConst.kt @@ -0,0 +1,20 @@ +package org.evomaster.core.problem.mcp + +object McpConst { + + /** + * JSON-RPC version used for all MCP messages, as required by the MCP specification. + */ + const val JSONRPC_VERSION = "2.0" + + /** + * MCP protocol version negotiated during the initialize handshake. + */ + const val PROTOCOL_VERSION = "2025-11-25" + + /** + * HTTP header used by the Streamable HTTP transport to carry the session id + * returned by the server after a successful initialize handshake. + */ + const val SESSION_ID_HEADER = "Mcp-Session-Id" +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 92e0c7e10a..96da4b14ed 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -1,6 +1,7 @@ package org.evomaster.core.problem.mcp.client import com.fasterxml.jackson.databind.ObjectMapper +import org.evomaster.core.problem.mcp.McpConst import org.evomaster.core.remote.HttpClientFactory import java.util.concurrent.atomic.AtomicInteger import javax.ws.rs.client.Client @@ -35,7 +36,7 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : /** Send a JSON-RPC message. [id] is omitted for notifications. */ private fun sendJsonRpc(method: String, params: Map, id: Int?, acceptEventStream: Boolean): Response { val payload = mutableMapOf( - "jsonrpc" to "2.0", + "jsonrpc" to McpConst.JSONRPC_VERSION, "method" to method, "params" to params ) @@ -44,7 +45,7 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : val acceptTypes = if (acceptEventStream) arrayOf("application/json", "text/event-stream") else arrayOf("application/json") var builder = client.target(baseUrl).request(*acceptTypes) - sessionId?.let { builder = builder.header("Mcp-Session-Id", it) } + sessionId?.let { builder = builder.header(McpConst.SESSION_ID_HEADER, it) } return builder.buildPost(Entity.entity(body, MediaType.APPLICATION_JSON_TYPE)).invoke() } @@ -57,7 +58,7 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : val response = sendJsonRpc( "initialize", mapOf( - "protocolVersion" to "2024-11-05", + "protocolVersion" to McpConst.PROTOCOL_VERSION, "capabilities" to emptyMap(), "clientInfo" to mapOf("name" to "EvoMaster", "version" to "1.0.0") ), @@ -71,7 +72,7 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : ) } // Capture session ID before reading the body - response.getHeaderString("Mcp-Session-Id")?.let { sessionId = it } + response.getHeaderString(McpConst.SESSION_ID_HEADER)?.let { sessionId = it } val responseBody = response.readEntity(String::class.java) if (responseBody.isBlank()) { throw IllegalStateException("MCP initialize handshake returned empty body") From ed9906d951a8f9f31823d741c0ad17a658fa5343 Mon Sep 17 00:00:00 2001 From: guido-rodriguez_sfemu Date: Sun, 12 Jul 2026 23:41:14 -0300 Subject: [PATCH 14/19] use JsonNode data type for tools input schema --- .../core/problem/mcp/client/HttpMcpClient.kt | 11 ++++--- .../core/problem/mcp/client/McpDTOs.kt | 10 +++--- .../core/problem/mcp/service/McpSampler.kt | 31 +++++++------------ 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 96da4b14ed..39a4a59f8d 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -1,5 +1,6 @@ package org.evomaster.core.problem.mcp.client +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import org.evomaster.core.problem.mcp.McpConst import org.evomaster.core.remote.HttpClientFactory @@ -111,9 +112,9 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : items.filterIsInstance>().forEach { t -> tools.add( McpToolDefinition( - name = t["name"] as? String ?: "", - description = t["description"] as? String ?: "", - inputSchema = t["inputSchema"] as? Map ?: emptyMap() + name = t["name"] as String, + description = t["description"] as String, + inputSchema = mapper.valueToTree(t["inputSchema"]) ) ) } @@ -133,8 +134,8 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : items.filterIsInstance>().forEach { r -> resources.add( McpResourceDefinition( - uri = r["uri"] as? String ?: "", - name = r["name"] as? String ?: "", + uri = r["uri"] as String, + name = r["name"] as String, description = r["description"] as? String ?: "", mimeType = r["mimeType"] as? String ) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt index e069c17654..1501c54f61 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt @@ -1,16 +1,18 @@ package org.evomaster.core.problem.mcp.client +import com.fasterxml.jackson.databind.JsonNode + /** Tool definition as returned by the MCP `tools/list` response. */ data class McpToolDefinition( val name: String, - val description: String = "", - val inputSchema: Map = emptyMap() + val description: String, + val inputSchema: JsonNode ) /** Static resource as returned by the MCP `resources/list` response. */ data class McpResourceDefinition( val uri: String, - val name: String = "", + val name: String, val description: String = "", val mimeType: String? = null ) @@ -18,7 +20,7 @@ data class McpResourceDefinition( /** URI-template resource as returned by the MCP `resources/templates/list` response. */ data class McpResourceTemplate( val uriTemplate: String, - val name: String = "", + val name: String, val description: String = "" ) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt index 90e171cfec..9baae6bc7e 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -1,5 +1,6 @@ package org.evomaster.core.problem.mcp.service +import com.fasterxml.jackson.databind.JsonNode import org.evomaster.client.java.controller.api.dto.SutInfoDto import org.evomaster.core.problem.api.service.ApiWsSampler import org.evomaster.core.remote.SutProblemException @@ -51,7 +52,7 @@ class McpSampler : ApiWsSampler() { @PostConstruct fun initialize() { val name = McpSampler::class.simpleName - val url = config.bbTargetUrl + val url = config.base log.debug("Initializing {}", name) @@ -66,19 +67,12 @@ class McpSampler : ApiWsSampler() { mcpClient.initialize() } catch (e: Exception) { throw SutProblemException( - "Failed to initialize MCP session at '${config.bbTargetUrl}'. Cause: ${e.message}" + "Failed to initialize MCP session at '${config.base}'. Cause: ${e.message}" ) } // Discover tools - val tools = try { - mcpClient.listTools() - } catch (e: Exception) { - throw SutProblemException( - "Failed to connect to MCP server at '${config.bbTargetUrl}'. " + - "Make sure the server is running and the URL is correct. Cause: ${e.message}" - ) - } + val tools = mcpClient.listTools() for (tool in tools) { val inputGene = buildObjectGeneFromSchema("input", tool.inputSchema) val action = McpToolCallAction( @@ -200,8 +194,8 @@ class McpSampler : ApiWsSampler() { * Supported types: string, integer, number, boolean, object, array. * Unknown or missing types fall back to [StringGene]. */ - internal fun buildGeneFromSchema(name: String, schema: Map): Gene { - val type = schema["type"] as? String + internal fun buildGeneFromSchema(name: String, schema: JsonNode): Gene { + val type = schema["type"]?.asText() return when (type) { "string" -> StringGene(name) @@ -214,15 +208,14 @@ class McpSampler : ApiWsSampler() { } /** - * Build an [ObjectGene] from a JSON Schema map. + * Build an [ObjectGene] from a JSON Schema node. * If the schema has no "properties" key, returns an empty [ObjectGene]. */ - internal fun buildObjectGeneFromSchema(name: String, schema: Map): ObjectGene { - val properties = schema["properties"] as? Map ?: emptyMap() - val fields = properties.entries.map { (propName, propSchema) -> - val propSchemaMap = propSchema as? Map ?: emptyMap() - buildGeneFromSchema(propName, propSchemaMap) - } + internal fun buildObjectGeneFromSchema(name: String, schema: JsonNode): ObjectGene { + val properties = schema["properties"] ?: return ObjectGene(name, emptyList()) + val fields = properties.fields().asSequence().map { (propName, propSchema) -> + buildGeneFromSchema(propName, propSchema) + }.toList() return ObjectGene(name, fields) } From d4c0c34266fdedd31e9a4a1e2878070ed463f5b6 Mon Sep 17 00:00:00 2001 From: guidorc Date: Sun, 12 Jul 2026 23:54:16 -0300 Subject: [PATCH 15/19] use MediaType constants --- .../org/evomaster/core/problem/mcp/client/HttpMcpClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 39a4a59f8d..7896a40377 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -44,7 +44,7 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : id?.let { payload["id"] = it } val body = mapper.writeValueAsString(payload) - val acceptTypes = if (acceptEventStream) arrayOf("application/json", "text/event-stream") else arrayOf("application/json") + val acceptTypes = if (acceptEventStream) arrayOf(MediaType.APPLICATION_JSON, MediaType.SERVER_SENT_EVENTS) else arrayOf(MediaType.APPLICATION_JSON) var builder = client.target(baseUrl).request(*acceptTypes) sessionId?.let { builder = builder.header(McpConst.SESSION_ID_HEADER, it) } From 4e6b78666002745fce3172ee7a78fdf7798ca551 Mon Sep 17 00:00:00 2001 From: guidorc Date: Mon, 13 Jul 2026 20:13:46 -0300 Subject: [PATCH 16/19] remove redundant HTTP status check --- .../org/evomaster/core/problem/mcp/client/HttpMcpClient.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 7896a40377..464816cd95 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -89,14 +89,9 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : try { response.close() } catch (_: Exception) {} } - /** Send a JSON-RPC request. Returns null on 4xx (method not supported). */ + /** Send a JSON-RPC request and parse the response. */ private fun post(method: String, params: Map = emptyMap()): Map? { val response = sendJsonRpc(method, params, nextId(), acceptEventStream = true) - val status = response.status - if (status == 400 || status == 404 || status == 405) { - response.close() - return null - } val responseBody = response.readEntity(String::class.java) return mapper.readValue(responseBody, Map::class.java) as Map } From 14b9b8dd5f168dd89066781ffbbc593a85faab37 Mon Sep 17 00:00:00 2001 From: guidorc Date: Mon, 13 Jul 2026 21:12:44 -0300 Subject: [PATCH 17/19] refactor MCP list response navigation --- .../core/problem/mcp/client/HttpMcpClient.kt | 111 +++++++++--------- 1 file changed, 57 insertions(+), 54 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index 464816cd95..e9a2c2fbd4 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -96,71 +96,74 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : return mapper.readValue(responseBody, Map::class.java) as Map } - override fun listTools(): List { - val tools = mutableListOf() + /** + * Fetches all pages from an MCP server response + * + * @param method the MCP method name + * @param resultKey the key in the result map containing the list of items + * @param transform function to convert each raw response to the target type + * @return list of all items + */ + private fun fetchPaginatedList( + method: String, + resultKey: String, + transform: (Map) -> T + ): List { + val results = mutableListOf() var cursor: String? = null + do { - val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() - val response = post("tools/list", params) ?: break - val result = response["result"] as? Map ?: break - val items = result["tools"] as? List<*> ?: emptyList() - items.filterIsInstance>().forEach { t -> - tools.add( - McpToolDefinition( - name = t["name"] as String, - description = t["description"] as String, - inputSchema = mapper.valueToTree(t["inputSchema"]) - ) - ) + // On first request cursor is null. + // On subsequent requests, include the cursor returned by the previous response. + val params = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() + + val response = post(method, params) + ?: throw IllegalStateException("$method request failed: received null response") + + val result = response["result"] as? Map + ?: throw IllegalStateException("$method response missing 'result' field or invalid type") + + val items = result[resultKey] as? List> ?: emptyList() + items.forEach { item -> + results.add(transform(item)) } + + // Extract cursor for next page cursor = result["nextCursor"] as? String } while (cursor != null) - return tools + + return results + } + + override fun listTools(): List { + return fetchPaginatedList("tools/list", "tools", { tool -> + McpToolDefinition( + name = tool["name"] as String, + description = tool["description"] as String, + inputSchema = mapper.valueToTree(tool["inputSchema"]) + ) + }) } override fun listResources(): List { - val resources = mutableListOf() - var cursor: String? = null - do { - val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() - val response = post("resources/list", params) ?: break - val result = response["result"] as? Map ?: break - val items = result["resources"] as? List<*> ?: emptyList() - items.filterIsInstance>().forEach { r -> - resources.add( - McpResourceDefinition( - uri = r["uri"] as String, - name = r["name"] as String, - description = r["description"] as? String ?: "", - mimeType = r["mimeType"] as? String - ) - ) - } - cursor = result["nextCursor"] as? String - } while (cursor != null) - return resources + return fetchPaginatedList("resources/list", "resources", { resource -> + McpResourceDefinition( + uri = resource["uri"] as String, + name = resource["name"] as String, + description = resource["description"] as? String ?: "", + mimeType = resource["mimeType"] as? String + ) + }) } override fun listResourceTemplates(): List { - val templates = mutableListOf() - var cursor: String? = null - do { - val params: Map = if (cursor != null) mapOf("cursor" to cursor) else emptyMap() - val response = post("resources/templates/list", params) ?: break - val result = response["result"] as? Map ?: break - val items = result["resourceTemplates"] as? List<*> ?: emptyList() - items.filterIsInstance>().forEach { t -> - templates.add( - McpResourceTemplate( - uriTemplate = t["uriTemplate"] as? String ?: "", - name = t["name"] as? String ?: "", - description = t["description"] as? String ?: "" - ) - ) - } - cursor = result["nextCursor"] as? String - } while (cursor != null) - return templates + return fetchPaginatedList("resources/templates/list", "resourceTemplates", { template -> + McpResourceTemplate( + uriTemplate = template["uriTemplate"] as String, + name = template["name"] as String, + description = template["description"] as? String ?: "" + ) + }) } override fun callTool(name: String, arguments: Map): McpToolResult { From 76948f5dbf3a08694bb83dd51aca037f5a20500e Mon Sep 17 00:00:00 2001 From: guidorc Date: Mon, 13 Jul 2026 21:50:14 -0300 Subject: [PATCH 18/19] Validate tool result includes type --- .../core/problem/mcp/client/HttpMcpClient.kt | 16 ++++++++++------ .../evomaster/core/problem/mcp/client/McpDTOs.kt | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt index e9a2c2fbd4..f6d77eafd1 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClient.kt @@ -9,6 +9,9 @@ import javax.ws.rs.client.Client import javax.ws.rs.client.Entity import javax.ws.rs.core.MediaType import javax.ws.rs.core.Response +import kotlin.String +import kotlin.collections.List +import kotlin.collections.Map /** * [McpClient] implementation that uses the Streamable HTTP transport. @@ -170,10 +173,12 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : val response = post("tools/call", mapOf("name" to name, "arguments" to arguments)) ?: return McpToolResult(isError = true) val result = response["result"] as? Map ?: return McpToolResult(isError = true) - val rawContent = result["content"] as? List<*> ?: emptyList() - val content = rawContent.filterIsInstance>().map { c -> + val rawContent = result["content"] as? List> ?: emptyList() + val content = rawContent.map { c -> + val type = c["type"] as? String + ?: throw IllegalStateException("tools/call response content item missing required 'type' field") McpContent( - type = c["type"] as? String ?: "text", + type = type, text = c["text"] as? String, uri = c["uri"] as? String, mimeType = c["mimeType"] as? String @@ -189,10 +194,9 @@ class HttpMcpClient(private val baseUrl: String, readTimeoutMs: Int = 60_000) : val response = post("resources/read", mapOf("uri" to uri)) ?: return McpResourceResult() val result = response["result"] as? Map ?: return McpResourceResult() - val rawContents = result["contents"] as? List<*> ?: emptyList() - val contents = rawContents.filterIsInstance>().map { c -> + val rawContents = result["contents"] as? List> ?: emptyList() + val contents = rawContents.map { c -> McpContent( - type = c["type"] as? String ?: "text", text = c["text"] as? String, uri = c["uri"] as? String, mimeType = c["mimeType"] as? String diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt index 1501c54f61..96a742f961 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/client/McpDTOs.kt @@ -37,7 +37,7 @@ data class McpResourceResult( /** Content item within a tool or resource response. */ data class McpContent( - val type: String, + val type: String? = null, val text: String? = null, val uri: String? = null, val mimeType: String? = null From 9517441613ecae2482022da811da145fa4d2ddfb Mon Sep 17 00:00:00 2001 From: guidorc Date: Mon, 13 Jul 2026 23:35:50 -0300 Subject: [PATCH 19/19] general feedback --- .../core/problem/mcp/McpIndividual.kt | 1 - .../core/problem/mcp/service/McpSampler.kt | 15 ++++--------- .../problem/mcp/client/HttpMcpClientTest.kt | 22 +++++++++---------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt index c59d230882..e8052ec9df 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/McpIndividual.kt @@ -2,7 +2,6 @@ package org.evomaster.core.problem.mcp import org.evomaster.core.problem.api.ApiWsIndividual import org.evomaster.core.problem.enterprise.EnterpriseChildTypeVerifier -import org.evomaster.core.problem.enterprise.EnterpriseIndividual.Companion.getEnterpriseTopGroups import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.search.GroupsOfChildren import org.evomaster.core.search.Individual diff --git a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt index 9baae6bc7e..1c0221e532 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/mcp/service/McpSampler.kt @@ -149,7 +149,7 @@ class McpSampler : ApiWsSampler() { override fun hasSpecialInitForSmartSampler(): Boolean = adHocInitialIndividuals.isNotEmpty() override fun initSeededTests(infoDto: SutInfoDto?) { - // Not supported in Phase 3 + throw UnsupportedOperationException("MCP seeded testing is not yet supported") } // ------------------------------------------------------------------------- @@ -159,15 +159,8 @@ class McpSampler : ApiWsSampler() { private fun customizeAdHocInitialIndividuals() { adHocInitialIndividuals.clear() - for ((_, action) in toolActionCluster) { - val copy = action.copy() as McpAction - copy.doInitialize(randomness) - val ind = McpIndividual(SampleType.RANDOM, mutableListOf(makeGroup(copy))) - ind.doGlobalInitialize(searchGlobalState) - adHocInitialIndividuals.add(ind) - } - - for ((_, action) in resourceActionCluster) { + val mcpActions = toolActionCluster.values + resourceActionCluster.values + for (action in mcpActions) { val copy = action.copy() as McpAction copy.doInitialize(randomness) val ind = McpIndividual(SampleType.RANDOM, mutableListOf(makeGroup(copy))) @@ -203,7 +196,7 @@ class McpSampler : ApiWsSampler() { "boolean" -> BooleanGene(name) "object" -> buildObjectGeneFromSchema(name, schema) "array" -> ArrayGene(name, StringGene("element")) - else -> StringGene(name) // fallback for unknown/null types + else -> throw IllegalArgumentException("Unsupported type in tool $name input schema: $type") } } diff --git a/core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt b/core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt index bc3be7d232..b03e7eafd3 100644 --- a/core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/problem/mcp/client/HttpMcpClientTest.kt @@ -39,7 +39,7 @@ class HttpMcpClientTest { } @Test - fun `listTools parses tool definitions correctly`() { + fun testListToolsParsesDefinitionsCorrectly() { stubPost( """{"jsonrpc":"2.0","result":{"tools":[{"name":"foo","description":"bar","inputSchema":{}}]},"id":1}""" ) @@ -52,7 +52,7 @@ class HttpMcpClientTest { } @Test - fun `listTools handles pagination with nextCursor`() { + fun testListToolsHandlesPagination() { // First page wm.stubFor( WireMock.post(urlEqualTo("/mcp")) @@ -91,7 +91,7 @@ class HttpMcpClientTest { } @Test - fun `callTool with isError false returns success result`() { + fun testCallToolReturnsSuccessResult() { stubPost( """{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"hello"}],"isError":false},"id":1}""" ) @@ -105,7 +105,7 @@ class HttpMcpClientTest { } @Test - fun `callTool with isError true returns error result`() { + fun testCallToolReturnsErrorResult() { stubPost( """{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"error message"}],"isError":true},"id":1}""" ) @@ -118,7 +118,7 @@ class HttpMcpClientTest { } @Test - fun `callTool with missing result returns isError true`() { + fun testCallToolWithMissingResultReturnsError() { stubPost( """{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}""" ) @@ -130,21 +130,21 @@ class HttpMcpClientTest { } @Test - fun `readResource parses content correctly`() { + fun testReadResourceParsesContentCorrectly() { stubPost( - """{"jsonrpc":"2.0","result":{"contents":[{"type":"text","text":"resource content","uri":"file:///data/res"}]},"id":1}""" + """{"jsonrpc":"2.0","result":{"contents":[{"text":"resource content","uri":"file:///data/res","mimeType":"text/plain"}]},"id":1}""" ) val result = client.readResource("file:///data/res") assertEquals(1, result.contents.size) - assertEquals("text", result.contents[0].type) assertEquals("resource content", result.contents[0].text) assertEquals("file:///data/res", result.contents[0].uri) + assertEquals("text/plain", result.contents[0].mimeType) } @Test - fun `readResource with missing result returns empty contents`() { + fun testReadResourceWithMissingResultReturnsEmpty() { stubPost( """{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}""" ) @@ -155,7 +155,7 @@ class HttpMcpClientTest { } @Test - fun `listResources parses resource definitions correctly`() { + fun testListResourcesParsesDefinitionsCorrectly() { stubPost( """{"jsonrpc":"2.0","result":{"resources":[{"uri":"file:///data","name":"data","description":"A data resource","mimeType":"application/json"}]},"id":1}""" ) @@ -169,7 +169,7 @@ class HttpMcpClientTest { } @Test - fun `listResourceTemplates parses templates correctly`() { + fun testListResourceTemplatesParsesTemplatesCorrectly() { stubPost( """{"jsonrpc":"2.0","result":{"resourceTemplates":[{"uriTemplate":"file:///{path}","name":"fileTemplate","description":"A file template"}]},"id":1}""" )