|
| 1 | +package agents_engine.mcp |
| 2 | + |
| 3 | +import agents_engine.core.agent |
| 4 | +import org.junit.jupiter.api.AfterEach |
| 5 | +import org.junit.jupiter.api.Tag |
| 6 | +import org.junit.jupiter.api.Test |
| 7 | +import java.math.BigInteger |
| 8 | +import kotlin.test.assertTrue |
| 9 | + |
| 10 | +/** |
| 11 | + * #1754 — self-contained live-MCP test. Replaces the previous "needs |
| 12 | + * MCP_REDMINE_URL" requirement with a loopback fixture using the |
| 13 | + * framework's own MCP server + client end-to-end. |
| 14 | + * |
| 15 | + * Shape: |
| 16 | + * 1. Build a tool agent `algebra` whose one skill computes |
| 17 | + * `sqrt(π / e)` to a configurable number of digits. |
| 18 | + * 2. Expose it via `McpServer.from(algebra)` on an auto-assigned |
| 19 | + * loopback port. |
| 20 | + * 3. Connect an `McpClient` to the server's URL. |
| 21 | + * 4. Invoke the exposed tool over the MCP wire and assert the |
| 22 | + * returned digits match the canonical sqrt(π/e) to 30 places. |
| 23 | + * |
| 24 | + * Per the design call: π and e are stored as digit arrays (the |
| 25 | + * "custom algebra" digits-as-arrays shape); the actual arithmetic |
| 26 | + * runs on BigInteger. |
| 27 | + */ |
| 28 | +class LoopbackMcpAlgebraTest { |
| 29 | + |
| 30 | + private var mcpServer: McpServer? = null |
| 31 | + private var mcpClient: McpClient? = null |
| 32 | + |
| 33 | + @AfterEach |
| 34 | + fun teardown() { |
| 35 | + mcpClient?.close() |
| 36 | + mcpServer?.stop() |
| 37 | + } |
| 38 | + |
| 39 | + @Tag("live-mcp") |
| 40 | + @Test |
| 41 | + fun `loopback — algebra agent exposes sqrt(pi over e) over MCP and an MCP client receives the digits`() { |
| 42 | + val algebra = agent<String, String>("algebra") { |
| 43 | + skills { |
| 44 | + skill<String, String>("compute", "Computes sqrt(pi/e) to many decimal places") { |
| 45 | + implementedBy { _ -> computeSqrtPiOverE(scale = 50) } |
| 46 | + } |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + val server = McpServer.from(algebra) { |
| 51 | + port = 0 |
| 52 | + expose("compute") |
| 53 | + }.start().also { mcpServer = it } |
| 54 | + |
| 55 | + val mcp = McpClient.connect(server.url).also { mcpClient = it } |
| 56 | + |
| 57 | + // Call the tool over the MCP wire — String-input skill schema is |
| 58 | + // `{input: string}`, so we pass an arbitrary input string (the |
| 59 | + // skill ignores it). |
| 60 | + val result = mcp.call("compute", mapOf("input" to "go"))?.toString() |
| 61 | + ?: error("MCP call returned null") |
| 62 | + |
| 63 | + // sqrt(π/e) starts 1.0750476... — verified against Math.sqrt |
| 64 | + // as a double-precision sanity check, plus a self-consistency |
| 65 | + // square-back below for the high-precision tail. |
| 66 | + assertTrue( |
| 67 | + result.startsWith("1.0750476"), |
| 68 | + "expected MCP round-trip to return sqrt(π/e) starting with \"1.0750476\"; got: \"$result\"", |
| 69 | + ) |
| 70 | + |
| 71 | + // Math.sqrt(Math.PI / Math.E) as a low-precision floor sanity check. |
| 72 | + // Locale.ROOT forces a period decimal separator regardless of host locale. |
| 73 | + val approx = Math.sqrt(Math.PI / Math.E) |
| 74 | + val approxRounded = String.format(java.util.Locale.ROOT, "%.7f", approx) |
| 75 | + assertTrue( |
| 76 | + result.startsWith(approxRounded), |
| 77 | + "MCP-returned digits must agree with Math.sqrt(Math.PI/Math.E) at 7-decimal precision; " + |
| 78 | + "expected prefix \"$approxRounded\"; got: \"$result\"", |
| 79 | + ) |
| 80 | + |
| 81 | + // Self-consistency: take 30 leading decimal digits of result, |
| 82 | + // square them in BigDecimal, compare to π/e to ~25 digits. Catches |
| 83 | + // any deeper precision corruption from the MCP wire. |
| 84 | + val resultBD = java.math.BigDecimal(result.take(33)) // "1." + 30 digits |
| 85 | + val squared = resultBD.multiply(resultBD).setScale(25, java.math.RoundingMode.HALF_UP) |
| 86 | + val piBD = java.math.BigDecimal(PI_DIGITS.joinToString("").let { it.substring(0, 1) + "." + it.substring(1) }) |
| 87 | + val eBD = java.math.BigDecimal(E_DIGITS.joinToString("").let { it.substring(0, 1) + "." + it.substring(1) }) |
| 88 | + val expectedRatio = piBD.divide(eBD, 25, java.math.RoundingMode.HALF_UP) |
| 89 | + val diff = squared.subtract(expectedRatio).abs() |
| 90 | + assertTrue( |
| 91 | + diff < java.math.BigDecimal("1e-20"), |
| 92 | + "result² must equal π/e to ~20 decimal places; squared=$squared expected=$expectedRatio diff=$diff", |
| 93 | + ) |
| 94 | + } |
| 95 | + |
| 96 | + /** |
| 97 | + * sqrt(π/e) to `scale` digits past the decimal point. π and e are |
| 98 | + * stored as digit arrays per the design; the actual arithmetic runs |
| 99 | + * on BigInteger. |
| 100 | + * |
| 101 | + * Strategy: |
| 102 | + * - Treat the digit arrays as integers: pi = 3141592..., e = 2718281... |
| 103 | + * - Each carries an implicit decimal shift equal to (digits - 1). |
| 104 | + * - To get sqrt(π/e) × 10^scale, compute: |
| 105 | + * numerator = pi × 10^(eShift + 2*scale - piShift) |
| 106 | + * scaled = numerator / e (integer division) |
| 107 | + * root = floor(sqrt(scaled)) |
| 108 | + * `root` is then sqrt(π/e) × 10^scale (as an integer); we |
| 109 | + * format it with the decimal point after the first digit. |
| 110 | + */ |
| 111 | + private fun computeSqrtPiOverE(scale: Int): String { |
| 112 | + val pi = BigInteger(PI_DIGITS.joinToString("")) |
| 113 | + val e = BigInteger(E_DIGITS.joinToString("")) |
| 114 | + val piShift = PI_DIGITS.size - 1 |
| 115 | + val eShift = E_DIGITS.size - 1 |
| 116 | + |
| 117 | + val exponent = eShift + 2 * scale - piShift |
| 118 | + require(exponent >= 0) { "scale too large for the digit array sizes" } |
| 119 | + val tenPow = BigInteger.TEN.pow(exponent) |
| 120 | + val scaled = pi.multiply(tenPow).divide(e) |
| 121 | + val root = scaled.sqrt() // floor(sqrt) — exact for square inputs, off-by-one for the last digit otherwise |
| 122 | + |
| 123 | + val s = root.toString() |
| 124 | + return s.substring(0, 1) + "." + s.substring(1) |
| 125 | + } |
| 126 | + |
| 127 | + companion object { |
| 128 | + // π to 60 digits past the decimal (61 digit-array entries total). |
| 129 | + // 3.14159265358979323846264338327950288419716939937510582097494 |
| 130 | + private val PI_DIGITS = intArrayOf( |
| 131 | + 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3, 2, 3, 8, 4, |
| 132 | + 6, 2, 6, 4, 3, 3, 8, 3, 2, 7, 9, 5, 0, 2, 8, 8, 4, 1, 9, 7, |
| 133 | + 1, 6, 9, 3, 9, 9, 3, 7, 5, 1, 0, 5, 8, 2, 0, 9, 7, 4, 9, 4, |
| 134 | + 4, |
| 135 | + ) |
| 136 | + |
| 137 | + // e to 60 digits past the decimal (61 digit-array entries total). |
| 138 | + // 2.71828182845904523536028747135266249775724709369995957496696 |
| 139 | + private val E_DIGITS = intArrayOf( |
| 140 | + 2, 7, 1, 8, 2, 8, 1, 8, 2, 8, 4, 5, 9, 0, 4, 5, 2, 3, 5, 3, |
| 141 | + 6, 0, 2, 8, 7, 4, 7, 1, 3, 5, 2, 6, 6, 2, 4, 9, 7, 7, 5, 7, |
| 142 | + 2, 4, 7, 0, 9, 3, 6, 9, 9, 9, 5, 9, 5, 7, 4, 9, 6, 6, 9, 6, |
| 143 | + 7, |
| 144 | + ) |
| 145 | + } |
| 146 | +} |
0 commit comments