Skip to content

Commit 4723ae5

Browse files
Skobeltsynclaude
andcommitted
test(#1794): convert McpClientLiveTest + AgentMcpToolUseTest to loopback fixtures
Three live-mcp tests previously skipped without MCP_REDMINE_URL. After this conversion they all run by default — each stands up a loopback McpServer.from(agent) with a `redmine_whoami` skill that returns a canned admin identity, then connects an McpClient to the auto-assigned loopback URL. The skill name `redmine_whoami` is preserved so the tool-name assertion shape still reflects what a real-world MCP-discovered Redmine integration would surface. AgentMcpToolUseTest stays live-llm tagged (uses Ollama for the agentic loop) — MCP side is loopback, LLM side is still real. Prompt update for AgentMcpToolUseTest: explicitly instruct the LLM to call the tool with `{"input": ""}`. The String-input skill schema McpServer.from generates requires the `input` field; the LLM needs to know that. Previous version assumed the real Redmine MCP tool was no-args, which doesn't match the loopback shape. After this lands: - `./gradlew mcpIntegrationTest` — 4 tests, 0 skipped, 0 failures - `./gradlew testAll` — fully self-contained, no external MCP setup Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 92ffaef commit 4723ae5

2 files changed

Lines changed: 101 additions & 18 deletions

File tree

src/test/kotlin/agents_engine/mcp/AgentMcpToolUseTest.kt

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,65 @@
11
package agents_engine.mcp
22

33
import agents_engine.core.agent
4+
import org.junit.jupiter.api.AfterEach
45
import org.junit.jupiter.api.Assumptions.assumeTrue
56
import org.junit.jupiter.api.Tag
67
import kotlin.test.Test
78
import kotlin.test.assertTrue
9+
import java.net.URI
10+
import java.net.http.HttpClient
11+
import java.net.http.HttpRequest
12+
import java.net.http.HttpResponse
13+
import java.time.Duration
814

15+
/**
16+
* #1794 — converted to a loopback MCP fixture. The MCP wire is in-JVM;
17+
* Ollama is still required for the agentic side, so this stays `live-llm`
18+
* tagged. When Ollama isn't reachable the test skips cleanly.
19+
*
20+
* Demonstrates: an agent discovers tools from a (loopback) MCP server,
21+
* routes a user question through Ollama, and the LLM invokes the
22+
* discovered tool. End-to-end MCP-as-tools integration.
23+
*/
924
class AgentMcpToolUseTest {
1025

26+
private var mcpServer: McpServer? = null
27+
private var mcpClient: McpClient? = null
28+
29+
@AfterEach
30+
fun teardown() {
31+
mcpClient?.close()
32+
mcpServer?.stop()
33+
}
34+
1135
@Tag("live-mcp")
1236
@Tag("live-llm")
1337
@Test
1438
fun `agent uses redmine MCP tool to identify the user`() {
15-
val mcpUrl = System.getenv("MCP_REDMINE_URL")
16-
assumeTrue(mcpUrl != null, "MCP_REDMINE_URL not set; skipping")
39+
assumeTrue(isOllamaReachable(), "skipping: no Ollama at localhost:11434")
40+
41+
// Loopback MCP server with the canonical Redmine-style identity tool.
42+
val whoamiAgent = agent<String, String>("redmine-loopback") {
43+
skills {
44+
skill<String, String>("redmine_whoami", "Returns the authenticated Redmine user") {
45+
implementedBy { _ -> "User: admin (id=1, email=admin@local)" }
46+
}
47+
}
48+
}
49+
val server = McpServer.from(whoamiAgent) {
50+
port = 0
51+
expose("redmine_whoami")
52+
}.start().also { mcpServer = it }
1753

18-
val mcp = McpClient.connect(mcpUrl!!)
54+
val mcp = McpClient.connect(server.url).also { mcpClient = it }
1955
val mcpTools = mcp.toolDefs()
2056
val toolNames = mcpTools.map { it.name }.toTypedArray()
2157

2258
val identifier = agent<String, String>("identifier") {
2359
prompt(
2460
"""You answer questions by calling tools when useful.
25-
|Call exactly one tool with empty arguments, then reply with a one-line summary of its result.
61+
|Call exactly one tool with arguments `{"input": ""}` (the tool's schema requires `input`),
62+
|then reply with a one-line summary of its result.
2663
""".trimMargin()
2764
)
2865
model { ollama("gpt-oss:120b-cloud"); host = "localhost"; port = 11434; temperature = 0.0 }
@@ -37,10 +74,23 @@ class AgentMcpToolUseTest {
3774
}
3875

3976
val answer = identifier("Who am I in Redmine? Call redmine_whoami with no arguments.")
40-
println("Agent answer: $answer")
77+
println("AgentMcpToolUseTest: agent answer: $answer")
4178
assertTrue(
4279
answer.contains("admin", ignoreCase = true),
4380
"expected mention of admin user, got: $answer",
4481
)
4582
}
83+
84+
private fun isOllamaReachable(): Boolean = try {
85+
val client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(500)).build()
86+
val request = HttpRequest.newBuilder()
87+
.uri(URI.create("http://localhost:11434/api/tags"))
88+
.timeout(Duration.ofMillis(1500))
89+
.GET()
90+
.build()
91+
val response = client.send(request, HttpResponse.BodyHandlers.discarding())
92+
response.statusCode() in 200..299
93+
} catch (_: Throwable) {
94+
false
95+
}
4696
}
Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,56 @@
11
package agents_engine.mcp
22

3-
import org.junit.jupiter.api.Assumptions.assumeTrue
3+
import agents_engine.core.agent
4+
import org.junit.jupiter.api.AfterEach
45
import org.junit.jupiter.api.Tag
56
import kotlin.test.Test
67
import kotlin.test.assertNotNull
78
import kotlin.test.assertTrue
89

10+
/**
11+
* #1794 — converted from MCP_REDMINE_URL-gated tests to a self-contained
12+
* loopback fixture. Each test spins up an agent with a `redmine_whoami`
13+
* skill via `McpServer.from(...)` and connects an `McpClient` to it.
14+
* No external infrastructure required.
15+
*
16+
* The skill name `redmine_whoami` is intentionally preserved so the
17+
* assertion shape (tool-name match) reflects what a real-world
18+
* MCP-discovered Redmine integration would surface.
19+
*/
920
class McpClientLiveTest {
1021

22+
private var server: McpServer? = null
23+
private var client: McpClient? = null
24+
25+
@AfterEach
26+
fun teardown() {
27+
client?.close()
28+
server?.stop()
29+
}
30+
31+
private fun loopbackUrl(): String {
32+
val whoamiAgent = agent<String, String>("redmine-loopback") {
33+
skills {
34+
skill<String, String>("redmine_whoami", "Returns the authenticated Redmine user") {
35+
implementedBy { _ -> "User: admin (id=1, email=admin@local)" }
36+
}
37+
}
38+
}
39+
val s = McpServer.from(whoamiAgent) {
40+
port = 0
41+
expose("redmine_whoami")
42+
}.start()
43+
server = s
44+
return s.url
45+
}
46+
1147
@Tag("live-mcp")
1248
@Test
1349
fun `connects to redmine mcp and exposes tools`() {
14-
val url = System.getenv("MCP_REDMINE_URL")
15-
assumeTrue(url != null, "MCP_REDMINE_URL not set; skipping")
50+
val c = McpClient.connect(loopbackUrl()).also { client = it }
51+
val tools = c.toolDefs()
1652

17-
val client = McpClient.connect(url!!)
18-
val tools = client.toolDefs()
19-
20-
assertTrue(tools.isNotEmpty(), "expected redmine to expose tools")
53+
assertTrue(tools.isNotEmpty(), "expected loopback server to expose tools")
2154
assertTrue(
2255
tools.any { it.name == "redmine_whoami" },
2356
"expected redmine_whoami tool, got: ${tools.map { it.name }}",
@@ -27,14 +60,14 @@ class McpClientLiveTest {
2760
@Tag("live-mcp")
2861
@Test
2962
fun `calls redmine_whoami and returns a non-empty result`() {
30-
val url = System.getenv("MCP_REDMINE_URL")
31-
assumeTrue(url != null, "MCP_REDMINE_URL not set; skipping")
32-
33-
val client = McpClient.connect(url!!)
34-
val result = client.call("redmine_whoami", emptyMap())
63+
val c = McpClient.connect(loopbackUrl()).also { client = it }
64+
val result = c.call("redmine_whoami", mapOf("input" to ""))
3565

3666
assertNotNull(result)
3767
assertTrue(result.toString().isNotBlank(), "whoami should return non-blank content")
38-
println("redmine_whoami → $result")
68+
assertTrue(
69+
result.toString().contains("admin", ignoreCase = true),
70+
"loopback whoami returns the canned admin user; got: $result",
71+
)
3972
}
4073
}

0 commit comments

Comments
 (0)