Skip to content

Commit 60bcc53

Browse files
Skobeltsynclaude
andcommitted
test(#889): McpServer lifecycle invariants — url/isRunning/stop/chainability
The existing tests cover error-path response codes (`McpServerErrorPathsTest`, `McpServerConformanceTest`) but never exercise the lifecycle state machine itself — `url` getter on an unstarted server, `isRunning()` transitions, `stop()` idempotency, `start()` chainability. Eight new tests target one specific mutant each: - `url` throws with "not started" message when never started (kills the `error("McpServer not started")` removal mutant at McpServer.kt:82). - `isRunning()` flips false→true on start, true→false on stop (catches always-true/always-false mutants on the boolean accessor). - `url` re-throws after stop (verifies `http = null` actually happens in `stop()`, not just `http?.stop(0)` being called). - `start()` returns `this` (chainability — `McpServer.from(...) { }.start()` would otherwise compile but break at call sites). - `stop()` called twice doesn't throw (idempotency — catches mutants that would make the second call NPE). - `url` shape contract: `http://localhost:<port>/mcp` with port > 0. - Two `port = 0` servers get distinct OS-assigned ports (proves the bind-to-zero path actually delegates to the OS rather than reusing a cached port). All eight pass on the current implementation. Together they kill ~6-8 PIT mutants in McpServer that the response-code tests can't reach (McpServer.kt:82, :84-90, :93, :95 from the #889 cluster list). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1129622 commit 60bcc53

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package agents_engine.mcp
2+
3+
import agents_engine.core.agent
4+
import kotlin.test.AfterTest
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertFails
8+
import kotlin.test.assertNotNull
9+
import kotlin.test.assertSame
10+
import kotlin.test.assertTrue
11+
import kotlin.test.assertFalse
12+
13+
// Tests for #889 — McpServer lifecycle invariants that were uncovered by the
14+
// existing conformance / error-path tests. Each test targets one
15+
// "removed-call" or "boundary" mutant in a specific lifecycle method.
16+
//
17+
// Killed mutants:
18+
// - L82: `error("McpServer not started")` — must throw when `http` is null
19+
// - L84-90: `start()` must return `this` (chainability)
20+
// - L93: `stop()` must null out `http` AND must be idempotent
21+
// - L95: `isRunning()` must reflect started / stopped state, not always true/false
22+
class McpServerLifecycleTest {
23+
24+
private val toStop = mutableListOf<() -> Unit>()
25+
@AfterTest fun cleanup() { toStop.forEach { runCatching { it() } } }
26+
27+
private fun trivialAgent() = agent<String, String>("greeter") {
28+
skills { skill<String, String>("greet", "Greets") { implementedBy { "hi $it" } } }
29+
}
30+
31+
private fun newServer() = McpServer.from(trivialAgent()) {
32+
expose("greet")
33+
port = 0
34+
}
35+
36+
@Test
37+
fun `url getter throws with clear message when server not started`() {
38+
val server = newServer()
39+
val e = assertFails { server.url }
40+
assertNotNull(e.message, "exception must carry a message")
41+
assertTrue(
42+
e.message!!.contains("not started", ignoreCase = true),
43+
"message should explain the lifecycle problem: '${e.message}'",
44+
)
45+
}
46+
47+
@Test
48+
fun `isRunning is false before start and true after start`() {
49+
val server = newServer()
50+
assertFalse(server.isRunning(), "isRunning must be false on a freshly-built server")
51+
server.start()
52+
toStop.add { server.stop() }
53+
assertTrue(server.isRunning(), "isRunning must be true after start() returns")
54+
}
55+
56+
@Test
57+
fun `isRunning is false after stop`() {
58+
val server = newServer().start()
59+
assertTrue(server.isRunning())
60+
server.stop()
61+
assertFalse(server.isRunning(), "isRunning must be false after stop()")
62+
}
63+
64+
@Test
65+
fun `url getter throws again after stop`() {
66+
val server = newServer().start()
67+
// Sanity — url works while running.
68+
assertNotNull(server.url)
69+
server.stop()
70+
val e = assertFails { server.url }
71+
assertTrue(
72+
(e.message ?: "").contains("not started", ignoreCase = true),
73+
"post-stop url should fail with the same not-started message: '${e.message}'",
74+
)
75+
}
76+
77+
@Test
78+
fun `start returns the same server instance for chaining`() {
79+
val server = newServer()
80+
val returned = server.start()
81+
toStop.add { server.stop() }
82+
assertSame(server, returned, "start() must return `this` so `McpServer.from(...) { }.start()` chains")
83+
}
84+
85+
@Test
86+
fun `stop is idempotent — calling twice does not throw`() {
87+
val server = newServer().start()
88+
server.stop()
89+
// Second stop is what would throw on a non-idempotent implementation.
90+
server.stop()
91+
assertFalse(server.isRunning(), "double-stop must leave server in stopped state")
92+
}
93+
94+
@Test
95+
fun `url contains the bound port and the mcp path`() {
96+
val server = newServer().start()
97+
toStop.add { server.stop() }
98+
val url = server.url
99+
assertTrue(url.startsWith("http://localhost:"), "url should start with http://localhost: but was '$url'")
100+
assertTrue(url.endsWith("/mcp"), "url should end with /mcp but was '$url'")
101+
// Port must be a real positive integer (port=0 → OS-assigned, never 0 after binding).
102+
val port = url.substringAfter("http://localhost:").substringBefore("/mcp").toInt()
103+
assertTrue(port > 0, "bound port must be > 0 (OS-assigned), got $port")
104+
}
105+
106+
@Test
107+
fun `port 0 produces different OS-assigned ports across instances`() {
108+
// Catches "bind always returns same port" mutants and gives a meaningful
109+
// assertion that the OS-assigned-port path actually does what it claims.
110+
val a = newServer().start(); toStop.add { a.stop() }
111+
val b = newServer().start(); toStop.add { b.stop() }
112+
val portA = a.url.substringAfter("http://localhost:").substringBefore("/mcp").toInt()
113+
val portB = b.url.substringAfter("http://localhost:").substringBefore("/mcp").toInt()
114+
assertEquals(
115+
2, setOf(portA, portB).size,
116+
"two port=0 servers must get distinct OS-assigned ports; got $portA and $portB",
117+
)
118+
}
119+
}

0 commit comments

Comments
 (0)