From 5cb28719d4a6a819d2885e90996098dfdcf4dd4a Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 15:39:00 +0200 Subject: [PATCH 01/72] feat: open proxy service seams for a headless caller Let a caller replace the filter and setting stores, pick a proxy target per HTTP server without changing the persisted selection, and learn the direction of each packet before its properties are published. Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/net/rsprox/proxy/ProxyService.kt | 11 ++++++----- .../main/kotlin/net/rsprox/shared/SessionMonitor.kt | 3 +++ .../net/rsprox/transcriber/state/SessionTracker.kt | 3 +++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt index ada0db3d..aed66840 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt @@ -127,9 +127,7 @@ public class ProxyService( public lateinit var jagexAccountStore: JagexAccountStore private set public lateinit var filterSetStore: PropertyFilterSetStore - private set public lateinit var settingsStore: SettingSetStore - private set private var properties: ProxyProperties by Delegates.notNull() private var availablePort: Int = -1 private var initialPort: Int = -1 @@ -860,12 +858,15 @@ public class ProxyService( return port - this.initialPort } - public fun initializeHttpServer(port: Int): ProxyTarget { + public fun initializeHttpServer( + port: Int, + config: ProxyTargetConfig = currentProxyTarget, + ): ProxyTarget { val sessionId = portOffset(port) val target = ProxyTarget( - currentProxyTarget, - GamePackProvider(currentProxyTarget.runeliteGamepackUrl), + config, + GamePackProvider(config.runeliteGamepackUrl), sessionId, ) target.load(properties, bootstrapFactory) diff --git a/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt b/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt index 8b2bb2d3..3d449d87 100644 --- a/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt +++ b/shared/src/main/kotlin/net/rsprox/shared/SessionMonitor.kt @@ -7,6 +7,9 @@ public interface SessionMonitor { /** Bind delayed callbacks to one recording rather than the latest active login. */ public fun forSession(header: T): SessionMonitor = this + /** Called on the transcriber worker before the properties of each packet are published. */ + public fun onPacketDirection(direction: StreamDirection) {} + public fun onLogin(header: T) public fun onLogout(header: T) diff --git a/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt b/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt index 051a9f3b..e1f2ec26 100644 --- a/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt +++ b/transcriber/src/main/kotlin/net/rsprox/transcriber/state/SessionTracker.kt @@ -40,6 +40,7 @@ import net.rsprox.protocol.game.outgoing.model.zone.header.UpdateZoneFullFollows import net.rsprox.protocol.game.outgoing.model.zone.header.UpdateZonePartialEnclosed import net.rsprox.protocol.game.outgoing.model.zone.header.UpdateZonePartialFollows import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection import net.rsprox.transcriber.firstOfInstanceOfNull import net.rsprox.transcriber.legacy.LegacyClientProt import net.rsprox.transcriber.legacy.LegacyServerProt @@ -62,6 +63,7 @@ public class SessionTracker( message: IncomingMessage, prot: net.rsprot.protocol.Prot, ) { + monitor.onPacketDirection(StreamDirection.SERVER_TO_CLIENT) try { processKeys(message) } catch (e: Exception) { @@ -121,6 +123,7 @@ public class SessionTracker( @Suppress("UNUSED_PARAMETER") message: IncomingMessage, prot: net.rsprot.protocol.Prot, ) { + monitor.onPacketDirection(StreamDirection.CLIENT_TO_SERVER) val toString = LegacyClientProt[prot.toString()] val clientProt = GameClientProt.valueOf(toString) setCurrentProt(clientProt) From 887e22877b7a484ef290d38840388d9b14871eb7 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 15:42:25 +0200 Subject: [PATCH 02/72] feat(mcp): add the mcp module with a JSON-RPC shell over HTTP A hand-written MCP endpoint on the JDK HttpServer and Jackson. It answers initialize, ping, tools/list and tools/call, checks tool arguments against each tool's schema, and refuses requests from a non-local Origin. Co-Authored-By: Claude Opus 5.5 --- mcp/build.gradle.kts | 17 + .../net/rsprox/mcp/server/McpHttpServer.kt | 319 ++++++++++++++++++ .../rsprox/mcp/server/McpDispatcherTest.kt | 200 +++++++++++ settings.gradle.kts | 1 + 4 files changed, 537 insertions(+) create mode 100644 mcp/build.gradle.kts create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt diff --git a/mcp/build.gradle.kts b/mcp/build.gradle.kts new file mode 100644 index 00000000..a141bb42 --- /dev/null +++ b/mcp/build.gradle.kts @@ -0,0 +1,17 @@ +dependencies { + implementation(projects.proxy) + implementation(projects.shared) + implementation(projects.cache.cacheApi) + implementation(platform(rootProject.libs.netty.bom)) + implementation(rootProject.libs.netty.buffer) + implementation(rootProject.libs.bundles.jackson) + implementation(rootProject.libs.clikt) + implementation(rootProject.libs.inline.logger) +} + +tasks.register("run") { + environment("APP_VERSION", project.version) + group = "run" + classpath = sourceSets["main"].runtimeClasspath + mainClass.set("net.rsprox.mcp.McpMainKt") +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt new file mode 100644 index 00000000..79aabed5 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -0,0 +1,319 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.michaelbull.logging.InlineLogger +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.URI +import java.net.URISyntaxException +import java.util.concurrent.Executors + +/** A failure the agent can act on. Becomes a tool result with `isError: true` and the message as its text. */ +public class ToolError( + message: String, +) : Exception(message) + +public sealed interface ToolResult { + /** One text block holding [value] as compact JSON. */ + public data class Json( + val value: Any, + ) : ToolResult + + /** One text block holding [text] as is. */ + public data class Text( + val text: String, + ) : ToolResult +} + +public class Tool( + public val name: String, + public val description: String, + public val inputSchema: ObjectNode, + /** Receives arguments that already satisfy [inputSchema]. */ + public val run: (args: ObjectNode) -> ToolResult, +) + +internal class HttpReply( + val status: Int, + val body: String?, +) + +private class RpcError( + val code: Int, + message: String, +) : Exception(message) + +/** The MCP protocol without the socket: one HTTP request in, one reply out. */ +internal class McpDispatcher( + tools: List, + private val version: String, +) { + private val tools = tools.associateBy { it.name } + + fun handle( + httpMethod: String, + origin: String?, + body: String, + ): HttpReply { + // A browser page on another site could otherwise reach this loopback server through DNS rebinding. + if (origin != null && !isLocalOrigin(origin)) return HttpReply(403, null) + if (httpMethod != "POST") return HttpReply(405, null) + val root = + try { + MAPPER.readTree(body) + } catch (e: JsonProcessingException) { + return HttpReply(400, error(null, PARSE_ERROR, "Parse error")) + } + if (root == null || !root.isObject) { + return HttpReply(400, error(null, INVALID_REQUEST, "Expected one JSON-RPC object; batching is not supported")) + } + val id = root.get("id")?.takeUnless { it.isNull } + val method = root.get("method")?.takeIf { it.isTextual }?.asText() + if (method == null) { + val isResponse = root.has("result") || root.has("error") + return if (isResponse) HttpReply(202, null) else HttpReply(400, error(id, INVALID_REQUEST, "Missing method")) + } + if (id == null) return HttpReply(202, null) + val result = + try { + call(method, root.get("params")) + } catch (e: RpcError) { + return HttpReply(200, error(id, e.code, e.message.orEmpty())) + } + val reply = MAPPER.createObjectNode() + reply.put("jsonrpc", "2.0") + reply.set("id", id) + reply.set("result", result) + return HttpReply(200, MAPPER.writeValueAsString(reply)) + } + + private fun call( + method: String, + params: JsonNode?, + ): JsonNode = + when (method) { + "initialize" -> initialize(params) + "ping" -> MAPPER.createObjectNode() + "tools/list" -> listTools() + "tools/call" -> callTool(params) + else -> throw RpcError(METHOD_NOT_FOUND, "Method not found: $method") + } + + private fun initialize(params: JsonNode?): JsonNode { + val requested = params?.get("protocolVersion")?.asText() + val result = MAPPER.createObjectNode() + result.put("protocolVersion", if (requested in SUPPORTED) requested else SUPPORTED.last()) + result.putObject("capabilities").putObject("tools") + result + .putObject("serverInfo") + .put("name", "rsprox") + .put("version", version) + return result + } + + private fun listTools(): JsonNode { + val result = MAPPER.createObjectNode() + val list = result.putArray("tools") + for (tool in tools.values) { + list + .addObject() + .put("name", tool.name) + .put("description", tool.description) + .set("inputSchema", tool.inputSchema) + } + return result + } + + private fun callTool(params: JsonNode?): JsonNode { + val name = + params?.get("name")?.takeIf { it.isTextual }?.asText() + ?: throw RpcError(INVALID_PARAMS, "tools/call requires params.name") + val tool = tools[name] ?: throw RpcError(INVALID_PARAMS, "Unknown tool: $name") + val arguments = params.get("arguments")?.takeUnless { it.isNull } ?: MAPPER.createObjectNode() + if (arguments !is ObjectNode) throw RpcError(INVALID_PARAMS, "params.arguments must be an object") + val result = MAPPER.createObjectNode() + val text = + try { + checkArguments(tool, arguments) + val text = render(tool.run(arguments)) + result.put("isError", false) + text + } catch (e: ToolError) { + result.put("isError", true) + e.message.orEmpty() + } catch (t: Throwable) { + logger.error(t) { "Tool $name failed" } + result.put("isError", true) + "internal: $t" + } + result + .putArray("content") + .addObject() + .put("type", "text") + .put("text", text) + return result + } + + private fun render(result: ToolResult): String = + when (result) { + is ToolResult.Json -> MAPPER.writeValueAsString(result.value) + is ToolResult.Text -> result.text + } + + /** + * Checks the subset of JSON Schema the tool table uses: `required`, per-property `type`, `enum`, + * `minimum`, `maximum`, and `items.type` for arrays. Unknown keys are rejected so a misspelt + * argument fails loudly instead of being ignored. + */ + private fun checkArguments( + tool: Tool, + arguments: ObjectNode, + ) { + val properties = tool.inputSchema.get("properties") ?: MAPPER.createObjectNode() + for (key in arguments.fieldNames()) { + if (!properties.has(key)) { + val allowed = properties.fieldNames().asSequence().joinToString(", ") + throw ToolError("${tool.name}: unknown argument '$key'. Allowed: $allowed") + } + } + for (required in tool.inputSchema.get("required") ?: emptyList()) { + if (!arguments.hasNonNull(required.asText())) { + throw ToolError("${tool.name}: missing required argument '${required.asText()}'") + } + } + for ((key, value) in arguments.fields()) { + val schema = properties.get(key) + val type = schema.get("type").asText() + if (!value.hasType(type)) throw ToolError("${tool.name}: argument '$key' must be of type $type") + val itemType = schema.get("items")?.get("type")?.asText() + if (itemType != null && value.any { !it.hasType(itemType) }) { + throw ToolError("${tool.name}: every item of '$key' must be of type $itemType") + } + val allowed = schema.get("enum") + if (allowed != null && allowed.none { it == value }) { + throw ToolError("${tool.name}: argument '$key' must be one of ${allowed.joinToString(", ") { it.asText() }}") + } + val minimum = schema.get("minimum")?.asLong() + if (minimum != null && value.asLong() < minimum) { + throw ToolError("${tool.name}: argument '$key' must be at least $minimum") + } + val maximum = schema.get("maximum")?.asLong() + if (maximum != null && value.asLong() > maximum) { + throw ToolError("${tool.name}: argument '$key' must be at most $maximum") + } + } + } + + private fun JsonNode.hasType(type: String): Boolean = + when (type) { + "string" -> isTextual + "integer" -> isIntegralNumber + "number" -> isNumber + "boolean" -> isBoolean + "array" -> isArray + "object" -> isObject + else -> error("Unsupported schema type: $type") + } + + private fun isLocalOrigin(origin: String): Boolean { + val host = + try { + URI(origin).host + } catch (e: URISyntaxException) { + null + } + return host in LOCAL_HOSTS + } + + private fun error( + id: JsonNode?, + code: Int, + message: String, + ): String { + val reply = MAPPER.createObjectNode() + reply.put("jsonrpc", "2.0") + reply.set("id", id ?: MAPPER.nullNode()) + reply + .putObject("error") + .put("code", code) + .put("message", message) + return MAPPER.writeValueAsString(reply) + } + + internal companion object { + internal val MAPPER: ObjectMapper = jacksonObjectMapper() + private val logger = InlineLogger() + private val SUPPORTED = listOf("2025-03-26", "2025-06-18", "2025-11-25") + private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "[::1]") + private const val PARSE_ERROR = -32700 + private const val INVALID_REQUEST = -32600 + private const val METHOD_NOT_FOUND = -32601 + private const val INVALID_PARAMS = -32602 + } +} + +/** + * Serves MCP over plain request/response HTTP on loopback. Every request runs on its own pool thread, + * so a tool may block for as long as it needs. + */ +public class McpHttpServer( + private val port: Int, + tools: List, + version: String, +) : AutoCloseable { + private val dispatcher = McpDispatcher(tools, version) + private var server: HttpServer? = null + + /** Throws [java.net.BindException] when the port is taken, which is how a second instance is refused. */ + public fun start() { + val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0) + server.executor = + Executors.newCachedThreadPool { runnable -> + Thread(runnable, "mcp-http").apply { isDaemon = true } + } + server.createContext(PATH, ::handle) + server.start() + this.server = server + } + + private fun handle(exchange: HttpExchange) { + try { + if (exchange.requestURI.path != PATH) { + exchange.sendResponseHeaders(404, -1) + return + } + val body = exchange.requestBody.readAllBytes().toString(Charsets.UTF_8) + val reply = + dispatcher.handle( + exchange.requestMethod, + exchange.requestHeaders.getFirst("Origin"), + body, + ) + if (reply.status == 405) exchange.responseHeaders.add("Allow", "POST") + if (reply.body == null) { + exchange.sendResponseHeaders(reply.status, -1) + return + } + val bytes = reply.body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(reply.status, bytes.size.toLong()) + exchange.responseBody.write(bytes) + } finally { + exchange.close() + } + } + + override fun close() { + server?.stop(0) + } + + private companion object { + private const val PATH = "/mcp" + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt new file mode 100644 index 00000000..85047f2f --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt @@ -0,0 +1,200 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class McpDispatcherTest { + private val mapper = McpDispatcher.MAPPER + + private fun schema(json: String): ObjectNode = mapper.readTree(json) as ObjectNode + + private val echo = + Tool( + name = "echo", + description = "Returns its text argument", + inputSchema = + schema( + """{"type":"object","properties":{"text":{"type":"string"},"times":{"type":"integer","minimum":1}, + "mode":{"type":"string","enum":["a","b"]},"tags":{"type":"array","items":{"type":"string"}}}, + "required":["text"]}""", + ), + ) { args -> ToolResult.Text(args.get("text").asText()) } + + private val json = + Tool("json", "Returns a map", schema("""{"type":"object","properties":{}}""")) { + ToolResult.Json(mapOf("answer" to 42)) + } + + private val refuse = + Tool("refuse", "Always fails", schema("""{"type":"object","properties":{}}""")) { + throw ToolError("no session s9") + } + + private val crash = + Tool("crash", "Always throws", schema("""{"type":"object","properties":{}}""")) { + throw IllegalStateException("boom") + } + + private val dispatcher = McpDispatcher(listOf(echo, json, refuse, crash), "1.2.3") + + private fun post( + body: String, + origin: String? = null, + ): HttpReply = dispatcher.handle("POST", origin, body) + + private fun result(body: String): JsonNode { + val reply = post(body) + assertEquals(200, reply.status) + val node = mapper.readTree(reply.body) + assertEquals("2.0", node.get("jsonrpc").asText()) + assertNull(node.get("error"), "unexpected error: ${reply.body}") + return node.get("result") + } + + private fun error(body: String): JsonNode = mapper.readTree(post(body).body).get("error") + + private fun callTool( + name: String, + arguments: String = "{}", + ): JsonNode = + result("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$name","arguments":$arguments}}""") + + @Test + fun `initialize echoes a supported protocol version and names the server`() { + val result = + result("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}""") + assertEquals("2025-06-18", result.get("protocolVersion").asText()) + assertTrue(result.get("capabilities").has("tools")) + assertEquals("rsprox", result.get("serverInfo").get("name").asText()) + assertEquals("1.2.3", result.get("serverInfo").get("version").asText()) + } + + @Test + fun `initialize answers an unknown protocol version with the newest supported one`() { + val result = + result("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}""") + assertEquals("2025-11-25", result.get("protocolVersion").asText()) + } + + @Test + fun `the response carries the request id unchanged`() { + val reply = mapper.readTree(post("""{"jsonrpc":"2.0","id":"abc","method":"ping"}""").body) + assertEquals("abc", reply.get("id").asText()) + assertEquals(0, reply.get("result").size()) + } + + @Test + fun `tools list returns every tool with its schema`() { + val tools = result("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""").get("tools") + assertEquals(listOf("echo", "json", "refuse", "crash"), tools.map { it.get("name").asText() }) + assertEquals("Returns its text argument", tools[0].get("description").asText()) + assertEquals(echo.inputSchema, tools[0].get("inputSchema")) + } + + @Test + fun `tools call returns the tool text as one content block`() { + val result = callTool("echo", """{"text":"hello"}""") + assertFalse(result.get("isError").asBoolean()) + assertEquals(1, result.get("content").size()) + assertEquals("text", result.get("content")[0].get("type").asText()) + assertEquals("hello", result.get("content")[0].get("text").asText()) + } + + @Test + fun `a json tool result is rendered as compact json text`() { + assertEquals("""{"answer":42}""", callTool("json").get("content")[0].get("text").asText()) + } + + @Test + fun `a ToolError becomes an error result carrying its message`() { + val result = callTool("refuse") + assertTrue(result.get("isError").asBoolean()) + assertEquals("no session s9", result.get("content")[0].get("text").asText()) + } + + @Test + fun `an unexpected exception becomes an internal error result`() { + val result = callTool("crash") + assertTrue(result.get("isError").asBoolean()) + assertTrue(result.get("content")[0].get("text").asText().startsWith("internal: ")) + } + + @Test + fun `arguments that break the schema are refused before the tool runs`() { + val cases = + mapOf( + """{}""" to "missing required argument 'text'", + """{"text":5}""" to "'text' must be of type string", + """{"text":"a","txt":"b"}""" to "unknown argument 'txt'", + """{"text":"a","times":0}""" to "'times' must be at least 1", + """{"text":"a","times":1.5}""" to "'times' must be of type integer", + """{"text":"a","mode":"c"}""" to "'mode' must be one of a, b", + """{"text":"a","tags":["x",1]}""" to "every item of 'tags' must be of type string", + ) + for ((arguments, expected) in cases) { + val result = callTool("echo", arguments) + assertTrue(result.get("isError").asBoolean(), arguments) + val text = result.get("content")[0].get("text").asText() + assertTrue(text.contains(expected), "$arguments gave: $text") + } + } + + @Test + fun `tools call without arguments runs the tool with an empty object`() { + val result = result("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"json"}}""") + assertFalse(result.get("isError").asBoolean()) + } + + @Test + fun `an unknown tool is an invalid params error`() { + val error = error("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nope"}}""") + assertEquals(-32602, error.get("code").asInt()) + } + + @Test + fun `an unknown method is a method not found error`() { + val reply = post("""{"jsonrpc":"2.0","id":7,"method":"resources/list"}""") + assertEquals(200, reply.status) + val node = mapper.readTree(reply.body) + assertEquals(7, node.get("id").asInt()) + assertEquals(-32601, node.get("error").get("code").asInt()) + assertNull(node.get("result")) + } + + @Test + fun `a notification is accepted with no body`() { + val reply = post("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + assertEquals(202, reply.status) + assertNull(reply.body) + } + + @Test + fun `a non-local origin is forbidden and a local one is served`() { + val ping = """{"jsonrpc":"2.0","id":1,"method":"ping"}""" + assertEquals(403, post(ping, origin = "https://example.com").status) + assertEquals(403, post(ping, origin = "null").status) + assertEquals(200, post(ping, origin = "http://localhost:6274").status) + assertEquals(200, post(ping, origin = "http://127.0.0.1:6274").status) + } + + @Test + fun `a batch or malformed body is rejected`() { + val batch = post("""[{"jsonrpc":"2.0","id":1,"method":"ping"}]""") + assertEquals(400, batch.status) + assertEquals(-32600, mapper.readTree(batch.body).get("error").get("code").asInt()) + val malformed = post("{not json") + assertEquals(400, malformed.status) + assertEquals(-32700, mapper.readTree(malformed.body).get("error").get("code").asInt()) + } + + @Test + fun `only POST is allowed`() { + assertEquals(405, dispatcher.handle("GET", null, "").status) + assertEquals(405, dispatcher.handle("DELETE", null, "").status) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a0ef670c..524ede5d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include( "shared", "launcher", "runelite", + "mcp", ) includeSubprojects(":protocol") From 8f0753523504424b533406aca657d8a3c7122c56 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 15:43:34 +0200 Subject: [PATCH 03/72] feat(mcp): add a bounded packet log read by cursor Records are contiguous, so a read after a cursor seeks in constant time and scans only the unseen tail. A read can wait for the first match, and reports ring eviction through a dropped count. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/packets/PacketLog.kt | 132 +++++++++++++++ .../net/rsprox/mcp/packets/PacketLogTest.kt | 156 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt new file mode 100644 index 00000000..8f91ddd5 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt @@ -0,0 +1,132 @@ +package net.rsprox.mcp.packets + +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** Position in one session's packet log. 0 is before the first record. Monotonic across logins and restarts. */ +@JvmInline +public value class Cursor( + public val seq: Long, +) + +/** Who produced a record: the game client, the game server, or rsprox itself (lifecycle markers). */ +public enum class Origin( + public val letter: Char, +) { + CLIENT('C'), + SERVER('S'), + PROXY('P'), +} + +public data class PacketRecord( + /** 1-based, contiguous, never reused. */ + val seq: Long, + /** Login epoch within the session, or 0 before the first login. */ + val login: Int, + /** Server tick within that login. Always 0 for [Origin.PROXY] records. */ + val cycle: Int, + val origin: Origin, + /** Upper-case prot name such as `IF_SETTEXT`, or a lifecycle marker such as `LOGIN`. */ + val prot: String, + /** Wall clock at append. Wire packets are flushed once per server tick, so this is tick-granular. */ + val atMs: Long, + val text: String, +) + +public data class PacketQuery( + val after: Cursor = Cursor(0), + /** Upper-case prot names. Empty matches every prot. */ + val prots: Set = emptySet(), + val origin: Origin? = null, + /** Case-insensitive substring of the record text. */ + val contains: String? = null, + val limit: Int = 200, +) + +public data class PacketPage( + val packets: List, + /** Pass as `after` next time. Everything up to here was either returned or did not match. */ + val next: Cursor, + val head: Cursor, + /** Records after the query's cursor that were evicted before they could be scanned. */ + val dropped: Long, + /** True only when a wait elapsed with no match. */ + val timedOut: Boolean, +) + +/** + * Bounded append-only log. Records are contiguous, so the record with sequence `seq` sits at index + * `seq - firstSeq` and a read after a cursor only scans the tail it has not seen. + */ +public class PacketLog( + private val capacity: Int = 100_000, +) { + private val lock = ReentrantLock() + private val appended = lock.newCondition() + private val records = ArrayDeque() + private var firstSeq = 1L + + internal fun append( + login: Int, + cycle: Int, + origin: Origin, + prot: String, + text: String, + ): Long = + lock.withLock { + val seq = firstSeq + records.size + records.addLast(PacketRecord(seq, login, cycle, origin, prot, System.currentTimeMillis(), text)) + if (records.size > capacity) { + records.removeFirst() + firstSeq++ + } + appended.signalAll() + seq + } + + /** + * With `waitMs` of 0 this returns whatever matches now. With a positive `waitMs` and nothing matching, + * it blocks until the first match arrives or the wait elapses. + */ + public fun read( + query: PacketQuery, + waitMs: Long = 0, + ): PacketPage = + lock.withLock { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) + val matches = ArrayList() + // A cursor past the head can only come from another log; reading from the head keeps it usable. + var scanned = query.after.seq.coerceIn(0, headSeq()) + var dropped = 0L + var timedOut = false + while (true) { + if (scanned + 1 < firstSeq) { + dropped += firstSeq - (scanned + 1) + scanned = firstSeq - 1 + } + while (scanned < headSeq() && matches.size < query.limit) { + scanned++ + val record = records[(scanned - firstSeq).toInt()] + if (query.matches(record)) matches += record + } + if (matches.isNotEmpty() || waitMs <= 0) break + val remaining = deadline - System.nanoTime() + if (remaining <= 0) { + timedOut = true + break + } + appended.awaitNanos(remaining) + } + PacketPage(matches, Cursor(scanned), Cursor(headSeq()), dropped, timedOut) + } + + public fun head(): Cursor = lock.withLock { Cursor(headSeq()) } + + private fun headSeq(): Long = firstSeq + records.size - 1 + + private fun PacketQuery.matches(record: PacketRecord): Boolean = + (prots.isEmpty() || record.prot in prots) && + (origin == null || record.origin == origin) && + (contains == null || record.text.contains(contains, ignoreCase = true)) +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt new file mode 100644 index 00000000..5a35bb38 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt @@ -0,0 +1,156 @@ +package net.rsprox.mcp.packets + +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PacketLogTest { + private fun PacketLog.add( + prot: String, + origin: Origin = Origin.SERVER, + text: String = "[${prot.lowercase()}]", + ): Long = append(login = 1, cycle = 0, origin = origin, prot = prot, text = text) + + private fun PacketPage.seqs(): List = packets.map { it.seq } + + @Test + fun `an empty log reads as empty at cursor zero`() { + val log = PacketLog() + val page = log.read(PacketQuery()) + assertEquals(emptyList(), page.packets) + assertEquals(Cursor(0), page.next) + assertEquals(Cursor(0), page.head) + assertEquals(0, page.dropped) + assertFalse(page.timedOut) + } + + @Test + fun `sequences start at one and reading from next returns only what came later`() { + val log = PacketLog() + assertEquals(1, log.add("A")) + assertEquals(2, log.add("B")) + val first = log.read(PacketQuery()) + assertEquals(listOf(1L, 2L), first.seqs()) + assertEquals(Cursor(2), first.next) + assertEquals(Cursor(2), log.head()) + + log.add("C") + val second = log.read(PacketQuery(after = first.next)) + assertEquals(listOf(3L), second.seqs()) + assertEquals(Cursor(3), second.next) + assertEquals(emptyList(), log.read(PacketQuery(after = second.next)).packets) + } + + @Test + fun `next skips past scanned records that did not match`() { + val log = PacketLog() + log.add("A") + log.add("B") + log.add("B") + val page = log.read(PacketQuery(prots = setOf("A"))) + assertEquals(listOf(1L), page.seqs()) + assertEquals(Cursor(3), page.next) + assertEquals(Cursor(3), page.head) + } + + @Test + fun `a page cut short by the limit resumes at the next unreturned record`() { + val log = PacketLog() + repeat(5) { log.add("A") } + val first = log.read(PacketQuery(limit = 2)) + assertEquals(listOf(1L, 2L), first.seqs()) + assertEquals(Cursor(2), first.next) + assertEquals(Cursor(5), first.head) + val rest = log.read(PacketQuery(after = first.next, limit = 10)) + assertEquals(listOf(3L, 4L, 5L), rest.seqs()) + assertEquals(Cursor(5), rest.next) + } + + @Test + fun `evicted records are reported as dropped and sequences are never reused`() { + val log = PacketLog(capacity = 3) + repeat(5) { log.add("A") } + val all = log.read(PacketQuery()) + assertEquals(listOf(3L, 4L, 5L), all.seqs()) + assertEquals(2, all.dropped) + + val partial = log.read(PacketQuery(after = Cursor(1))) + assertEquals(1, partial.dropped) + assertEquals(0, log.read(PacketQuery(after = Cursor(2))).dropped) + assertEquals(6, log.add("A")) + } + + @Test + fun `filters by prot, origin and text`() { + val log = PacketLog() + log.add("IF_BUTTON", Origin.CLIENT, "[if_button] com=558:7") + log.add("IF_SETTEXT", Origin.SERVER, "[if_settext] com=558:13, text=\"McpProto is available\"") + log.add("LOGIN", Origin.PROXY, "revision=241 world=1") + log.add("IF_SETTEXT", Origin.SERVER, "[if_settext] com=162:5, text=\"hello\"") + + assertEquals(listOf(2L, 4L), log.read(PacketQuery(prots = setOf("IF_SETTEXT"))).seqs()) + assertEquals(listOf(1L, 3L), log.read(PacketQuery(prots = setOf("IF_BUTTON", "LOGIN"))).seqs()) + assertEquals(listOf(1L), log.read(PacketQuery(origin = Origin.CLIENT)).seqs()) + assertEquals(listOf(3L), log.read(PacketQuery(origin = Origin.PROXY)).seqs()) + assertEquals(listOf(2L), log.read(PacketQuery(contains = "mcpproto")).seqs()) + assertEquals( + emptyList(), + log.read(PacketQuery(prots = setOf("IF_SETTEXT"), origin = Origin.CLIENT)).seqs(), + ) + } + + @Test + fun `a waiting read returns as soon as a matching record is appended`() { + val log = PacketLog() + log.add("A") + val read = + CompletableFuture.supplyAsync { + log.read(PacketQuery(after = Cursor(1), prots = setOf("WANTED")), waitMs = 30_000) + } + log.add("NOISE") + assertFalse(read.isDone) + log.add("WANTED") + val page = read.get(10, TimeUnit.SECONDS) + assertEquals(listOf(3L), page.seqs()) + assertEquals(Cursor(3), page.next) + assertFalse(page.timedOut) + } + + @Test + fun `a waiting read times out when nothing matches and still advances the cursor`() { + val log = PacketLog() + log.add("A") + val page = log.read(PacketQuery(prots = setOf("WANTED")), waitMs = 50) + assertTrue(page.timedOut) + assertEquals(emptyList(), page.packets) + assertEquals(Cursor(1), page.next) + } + + @Test + fun `a read without a wait never reports a timeout`() { + val log = PacketLog() + assertFalse(log.read(PacketQuery(prots = setOf("WANTED")), waitMs = 0).timedOut) + } + + @Test + fun `a waiting read with matches already present returns at once`() { + val log = PacketLog() + log.add("WANTED") + val page = log.read(PacketQuery(prots = setOf("WANTED")), waitMs = 30_000) + assertEquals(listOf(1L), page.seqs()) + assertFalse(page.timedOut) + } + + @Test + fun `a cursor beyond the head reads from the head`() { + val log = PacketLog() + log.add("A") + val page = log.read(PacketQuery(after = Cursor(99))) + assertEquals(Cursor(1), page.next) + log.add("B") + assertEquals(listOf(2L), log.read(PacketQuery(after = page.next)).seqs()) + } +} From ca5863110495a53ba1367be30192f3cb6e450598 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 15:46:59 +0200 Subject: [PATCH 04/72] feat(mcp): add headless sessions, the packet tap and four tools A session keeps its id, packet log and cursor across client restarts. Each launch takes fresh ports, is bounded by a deadline, and reports a proxy port that could not be bound. The tap formats every transcribed packet on the transcriber worker, with in-memory stores that enable every filter and turn off each setting that drops output. Tools: session_start, session_stop, session_list, packets_read. Run with: ./gradlew :mcp:run --args="--port 43580" Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 68 +++++ .../net/rsprox/mcp/bridge/BridgeLink.kt | 6 + .../net/rsprox/mcp/packets/PacketTap.kt | 123 ++++++++++ .../net/rsprox/mcp/packets/Unfiltered.kt | 145 +++++++++++ .../net/rsprox/mcp/server/McpHttpServer.kt | 8 +- .../kotlin/net/rsprox/mcp/server/Tools.kt | 170 +++++++++++++ .../mcp/session/ProxyServiceLauncher.kt | 64 +++++ .../kotlin/net/rsprox/mcp/session/Session.kt | 232 ++++++++++++++++++ .../net/rsprox/mcp/session/SessionManager.kt | 162 ++++++++++++ .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 110 +++++++++ .../rsprox/mcp/session/SessionManagerTest.kt | 164 +++++++++++++ .../net/rsprox/mcp/session/SessionTest.kt | 169 +++++++++++++ 12 files changed, 1418 insertions(+), 3 deletions(-) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt new file mode 100644 index 00000000..22a613ca --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -0,0 +1,68 @@ +package net.rsprox.mcp + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.int +import com.github.michaelbull.logging.InlineLogger +import io.netty.buffer.ByteBufAllocator +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.packets.UnfilteredFilterSetStore +import net.rsprox.mcp.server.McpHttpServer +import net.rsprox.mcp.server.ToolError +import net.rsprox.mcp.server.tools +import net.rsprox.mcp.session.ProxyServiceLauncher +import net.rsprox.mcp.session.SessionManager +import net.rsprox.proxy.ProxyService +import java.util.Locale +import java.util.concurrent.atomic.AtomicReference + +/** Runs rsprox without its GUI and serves it to an agent as an MCP server on loopback. */ +public class McpCommand : CliktCommand(name = "mcp") { + private val port by option("--port", help = "Loopback port of the MCP endpoint").int().default(43580) + private val portSkip by option( + "--port-skip", + help = "Proxy ports to leave unused at the start of the range, for a GUI running at the same time", + ).int().default(50) + private val autostart by option("--start", help = "Target name to launch immediately") + + override fun run() { + Locale.setDefault(Locale.US) + val sessions = AtomicReference() + val http = + McpHttpServer( + port, + tools { sessions.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, + System.getenv("APP_VERSION") ?: "dev", + ) + // Binding first makes a second instance exit here, before it touches the shared configuration. + http.start() + logger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } + + val service = ProxyService(ByteBufAllocator.DEFAULT) + service.start(null, null) { percentage, _, subActionText, _ -> + logger.debug { "Starting proxy service: $subActionText (${(percentage * 100).toInt()}%)" } + } + service.filterSetStore = UnfilteredFilterSetStore + service.settingsStore = TapSettingSetStore + val launcher = ProxyServiceLauncher(service, portSkip.coerceAtLeast(1)) + val manager = SessionManager(launcher, service.settingsStore) + sessions.set(manager) + logger.info { "Ready. Targets: ${manager.targets().joinToString(", ")}" } + + autostart?.let { target -> + try { + manager.start(target, null, 0) + } catch (e: ToolError) { + logger.error { "Unable to start '$target': ${e.message}" } + } + } + Thread.currentThread().join() + } + + private companion object { + private val logger = InlineLogger() + } +} + +public fun main(args: Array): Unit = McpCommand().main(args) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt new file mode 100644 index 00000000..a8d13cd2 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -0,0 +1,6 @@ +package net.rsprox.mcp.bridge + +/** One connected in-client bridge. Compared by identity, so a late event from an old link cannot match a new one. */ +public class BridgeLink internal constructor() { + internal fun close() {} +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt new file mode 100644 index 00000000..cd641ff9 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -0,0 +1,123 @@ +package net.rsprox.mcp.packets + +import net.rsprox.cache.api.CacheProvider +import net.rsprox.mcp.session.LoginRegistry +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.OmitFilteredPropertyTreeFormatter +import net.rsprox.shared.property.PropertyFormatterCollection +import net.rsprox.shared.property.PropertyTreeFormatter +import net.rsprox.shared.property.RootProperty +import net.rsprox.shared.settings.SettingSetStore +import net.rsprox.shared.symbols.SymbolDictionaryProvider + +/** + * The session monitor of one launch. The proxy only delivers callbacks to the per-login instance that + * [forSession] returns, so the callbacks of this class itself do nothing. + */ +internal class PacketTap( + private val log: PacketLog, + private val logins: LoginRegistry, + private val settings: SettingSetStore, +) : SessionMonitor { + override fun forSession(header: BinaryHeader): SessionMonitor = + LoginTap(log, logins, settings, logins.nextEpoch()) + + override fun onLogin(header: BinaryHeader) {} + + override fun onLogout(header: BinaryHeader) {} + + override fun onCacheUpdate(cacheProvider: CacheProvider) {} + + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} + + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} + + override fun onNameUpdate(name: String) {} + + override fun onUserInformationUpdate( + userId: Long, + userHash: Long, + ) {} + + override fun onTranscribe( + cycle: Int, + property: RootProperty, + ) {} +} + +internal class LoginTap( + private val log: PacketLog, + private val logins: LoginRegistry, + settings: SettingSetStore, + private val epoch: Int, +) : SessionMonitor { + // Written on a Netty thread when the transcriber is hooked, read on the transcriber worker. + @Volatile + private var cache: CacheProvider? = null + + // The two fields below are only touched on the transcriber worker. + private var direction = StreamDirection.SERVER_TO_CLIENT + private var transcribing = false + + private val formatter: PropertyTreeFormatter = + OmitFilteredPropertyTreeFormatter( + PropertyFormatterCollection.default(SymbolDictionaryProvider.get(), settings) { + cache?.get() ?: error("Cache unavailable") + }, + ) + + override fun onCacheUpdate(cacheProvider: CacheProvider) { + cache = cacheProvider + } + + override fun onPacketDirection(direction: StreamDirection) { + this.direction = direction + // The cache update arrives before the proxy knows whether a decoder exists for the revision, + // so the first decoded packet is the earliest proof that a transcriber is hooked. + if (!transcribing) { + transcribing = true + logins.update(epoch) { it.copy(transcribing = true) } + } + } + + /** Formats on the worker, while the transcriber's session state still matches the packet. */ + override fun onTranscribe( + cycle: Int, + property: RootProperty, + ) { + val origin = if (direction == StreamDirection.CLIENT_TO_SERVER) Origin.CLIENT else Origin.SERVER + val text = + try { + formatter.format(property).joinToString("\n") + } catch (e: Exception) { + // An exception here would unwind into the proxy's transcriber worker. + "[${property.prot.lowercase()}] " + } + log.append(epoch, cycle, origin, property.prot.uppercase(), text) + } + + override fun onLogin(header: BinaryHeader) { + logins.login(epoch, header.revision, header.worldId) + log.append(epoch, 0, Origin.PROXY, "LOGIN", "revision=${header.revision} world=${header.worldId}") + } + + override fun onLogout(header: BinaryHeader) { + logins.update(epoch) { it.copy(online = false) } + log.append(epoch, 0, Origin.PROXY, "LOGOUT", "world=${header.worldId}") + } + + override fun onNameUpdate(name: String) { + logins.update(epoch) { it.copy(name = name) } + } + + override fun onUserInformationUpdate( + userId: Long, + userHash: Long, + ) {} + + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} + + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt new file mode 100644 index 00000000..ddd97c7e --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt @@ -0,0 +1,145 @@ +package net.rsprox.mcp.packets + +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.filters.PropertyFilter +import net.rsprox.shared.filters.PropertyFilterSet +import net.rsprox.shared.filters.PropertyFilterSetStore +import net.rsprox.shared.filters.ProtCategory +import net.rsprox.shared.filters.RegexFilter +import net.rsprox.shared.settings.Setting +import net.rsprox.shared.settings.SettingCategory +import net.rsprox.shared.settings.SettingGroup +import net.rsprox.shared.settings.SettingSet +import net.rsprox.shared.settings.SettingSetStore + +/** + * Every filter enabled, no regex filters, nothing persisted. The stores on disk belong to the GUI, + * and every mutator of the default filter set saves to them. + */ +internal object UnfilteredFilterSetStore : PropertyFilterSetStore { + override val size: Int = 1 + + override fun create(name: String): PropertyFilterSet = AllEnabled + + override fun delete(index: Int): PropertyFilterSet? = null + + override fun get(index: Int): PropertyFilterSet? = if (index == 0) AllEnabled else null + + override fun getActive(): PropertyFilterSet = AllEnabled + + override fun setActive(index: Int) {} + + private object AllEnabled : PropertyFilterSet { + override fun getCreationTime(): Long = 0 + + override fun getName(): String = "unfiltered" + + override fun setName(name: String) {} + + override fun deleteBackingFile() {} + + override fun get(filter: PropertyFilter): Boolean = true + + override fun set( + filter: PropertyFilter, + enabled: Boolean, + ) {} + + override fun set( + category: ProtCategory, + enabled: Boolean, + ) {} + + override fun set( + streamDirection: StreamDirection, + enabled: Boolean, + ) {} + + override fun setAll(enabled: Boolean) {} + + override fun setDefaults() {} + + override fun getRegexFilters(): List = emptyList() + + override fun addRegexFilter(regexFilter: RegexFilter) {} + + override fun removeRegexFilter(regexFilter: RegexFilter) {} + + override fun replaceRegexFilter( + oldRegexFilter: RegexFilter, + newRegexFilter: RegexFilter, + ) {} + + override fun clearRegexFilters() {} + } +} + +/** + * The default of every setting, except that each setting the transcriber reads to drop output is off. + * Fixed in memory so the transcript does not depend on what the user ticked in the GUI. + * + * Settings that drop output, and where the transcriber reads them: + * - `SKIP_FIRST_TICK`: every packet of tick 0 (`TextTranscriber`, `TextRs3Transcriber`). + * - `HIDE_UNNECESSARY_VARPS`, `HIDE_SAME_VALUE_VARPS`: varp packets (`TextServerPacketTranscriber`). + * - `PLAYER_INFO_HIDE_EMPTY`: an empty player info packet (`TextPlayerInfoTranscriber`). + * - `PLAYER_INFO_LOCAL_PLAYER_ONLY`: updates of every other player (`TextPlayerInfoTranscriber`). + * - `NPC_INFO_HIDE_EMPTY`: an empty NPC info packet (`TextNpcInfoTranscriber`). + * - `WORLDENTITY_INFO_HIDE_EMPTY`: an empty world entity info packet (`TextServerPacketTranscriber`). + * - `HIDE_RS3_LOBBY`: RuneScape 3 lobby packets (`TextRs3Transcriber`). + */ +internal object TapSettingSetStore : SettingSetStore { + private val off = + setOf( + Setting.SKIP_FIRST_TICK, + Setting.HIDE_UNNECESSARY_VARPS, + Setting.HIDE_SAME_VALUE_VARPS, + Setting.PLAYER_INFO_HIDE_EMPTY, + Setting.PLAYER_INFO_LOCAL_PLAYER_ONLY, + Setting.NPC_INFO_HIDE_EMPTY, + Setting.WORLDENTITY_INFO_HIDE_EMPTY, + Setting.HIDE_RS3_LOBBY, + ) + + override val size: Int = 1 + + override fun create(name: String): SettingSet = Fixed + + override fun delete(index: Int): SettingSet? = null + + override fun get(index: Int): SettingSet? = if (index == 0) Fixed else null + + override fun getActive(): SettingSet = Fixed + + override fun setActive(index: Int) {} + + private object Fixed : SettingSet { + override fun getCreationTime(): Long = 0 + + override fun getName(): String = "mcp" + + override fun setName(name: String) {} + + override fun deleteBackingFile() {} + + override fun get(setting: Setting): Boolean = setting.enabled && setting !in off + + override fun set( + setting: Setting, + enabled: Boolean, + ) {} + + override fun set( + category: SettingCategory, + enabled: Boolean, + ) {} + + override fun set( + group: SettingGroup, + enabled: Boolean, + ) {} + + override fun setAll(enabled: Boolean) {} + + override fun setDefaults() {} + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt index 79aabed5..9d017660 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -71,13 +71,14 @@ internal class McpDispatcher( return HttpReply(400, error(null, PARSE_ERROR, "Parse error")) } if (root == null || !root.isObject) { - return HttpReply(400, error(null, INVALID_REQUEST, "Expected one JSON-RPC object; batching is not supported")) + return HttpReply(400, error(null, INVALID_REQUEST, "Expected one JSON-RPC object; batching is unsupported")) } val id = root.get("id")?.takeUnless { it.isNull } val method = root.get("method")?.takeIf { it.isTextual }?.asText() if (method == null) { val isResponse = root.has("result") || root.has("error") - return if (isResponse) HttpReply(202, null) else HttpReply(400, error(id, INVALID_REQUEST, "Missing method")) + if (isResponse) return HttpReply(202, null) + return HttpReply(400, error(id, INVALID_REQUEST, "Missing method")) } if (id == null) return HttpReply(202, null) val result = @@ -197,7 +198,8 @@ internal class McpDispatcher( } val allowed = schema.get("enum") if (allowed != null && allowed.none { it == value }) { - throw ToolError("${tool.name}: argument '$key' must be one of ${allowed.joinToString(", ") { it.asText() }}") + val values = allowed.joinToString(", ") { it.asText() } + throw ToolError("${tool.name}: argument '$key' must be one of $values") } val minimum = schema.get("minimum")?.asLong() if (minimum != null && value.asLong() < minimum) { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt new file mode 100644 index 00000000..1b92ce0b --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -0,0 +1,170 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import net.rsprox.mcp.packets.Cursor +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketPage +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.session.SessionManager + +/** + * The tool table. [sessions] is resolved on every call and may throw a [ToolError] while the proxy + * is still starting. + */ +public fun tools(sessions: () -> SessionManager): List = + listOf( + Tool( + name = "session_start", + description = + "Launch a RuneLite client through rsprox, or relaunch a stopped session. " + + "Returns once the client's launcher has started. With no arguments it starts a new session " + + "on the first custom target. Pass `session` to relaunch a stopped session on fresh ports; " + + "its packet log and cursor continue. Calling it for a session that is already running " + + "launches nothing.", + inputSchema = + schema( + "target" to string("Proxy target name, as listed by session_list. Example: \"My Server\"."), + "session" to string("Existing session id, such as \"s1\"."), + "wait_ms" to integer("How long to wait for the session to connect. Default 0.", 0, 600_000), + ), + ) { args -> + ToolResult.Json( + sessions().start( + target = args.text("target"), + session = args.text("session"), + waitMs = args.long("wait_ms") ?: 0, + ), + ) + }, + Tool( + name = "session_stop", + description = "Kill the client of a session. The session and its packets stay readable.", + inputSchema = schema("session" to SESSION), + ) { args -> + ToolResult.Json(sessions().stop(args.text("session"))) + }, + Tool( + name = "session_list", + description = "List every session with its state, ports, login and packet cursor, plus the target names.", + inputSchema = schema(), + ) { + val manager = sessions() + ToolResult.Json(mapOf("sessions" to manager.list(), "targets" to manager.targets())) + }, + Tool( + name = "packets_read", + description = + "Read decoded packets of a session after a cursor, unfiltered, in both directions. " + + "With `wait_ms` it blocks until the first match, which makes it the way to wait for a packet. " + + "Line 1 of the result is JSON: pass `next` as `after` on the following call; `dropped` counts " + + "records that were evicted before they could be read; `timedOut` is true when a wait " + + "elapsed with no match. Each further line is one packet: " + + "` L T `, where C is client to server, S is server " + + "to client and P is an rsprox marker (CLIENT_LAUNCHED, CLIENT_CONNECTED, CLIENT_EXITED, " + + "LOGIN, LOGOUT). Continuation lines of one packet are indented.", + inputSchema = + schema( + "session" to SESSION, + "after" to integer("Cursor to read after. Default 0, the start of the log.", 0), + "prots" to stringArray("Prot names to keep, such as [\"IF_SETTEXT\"]. Default: all."), + "origin" to string("Keep only one origin.", "client", "server", "proxy"), + "contains" to string("Case-insensitive substring the packet text must contain."), + "wait_ms" to integer("How long to block for a first match. Default 0.", 0, 120_000), + "limit" to integer("Maximum number of packets to return. Default 200.", 1, 1000), + ), + ) { args -> + val query = + PacketQuery( + after = Cursor(args.long("after") ?: 0), + prots = args.get("prots")?.map { it.asText().uppercase() }?.toSet().orEmpty(), + origin = args.text("origin")?.let { Origin.valueOf(it.uppercase()) }, + contains = args.text("contains"), + limit = args.long("limit")?.toInt() ?: 200, + ) + val page = sessions().resolve(args.text("session")).packets.read(query, args.long("wait_ms") ?: 0) + ToolResult.Text(render(page)) + }, + ) + +private val SESSION: ObjectNode = string("Session id, such as \"s1\". May be omitted while only one session exists.") + +private fun render(page: PacketPage): String { + val meta = + linkedMapOf( + "next" to page.next.seq, + "head" to page.head.seq, + "dropped" to page.dropped, + "timedOut" to page.timedOut, + "count" to page.packets.size, + ) + val out = StringBuilder(McpDispatcher.MAPPER.writeValueAsString(meta)) + for (packet in page.packets) { + out + .append('\n') + .append(packet.seq) + .append(" L") + .append(packet.login) + .append(" T") + .append(packet.cycle) + .append(' ') + .append(packet.origin.letter) + .append(' ') + .append(packet.prot) + .append(' ') + .append(packet.text.replace("\n", "\n ")) + } + return out.toString() +} + +private fun schema(vararg properties: Pair): ObjectNode { + val schema = McpDispatcher.MAPPER.createObjectNode() + schema.put("type", "object") + val node = schema.putObject("properties") + for ((name, property) in properties) { + node.set(name, property) + } + return schema +} + +private fun string( + description: String, + vararg allowed: String, +): ObjectNode { + val node = property("string", description) + if (allowed.isNotEmpty()) { + val values = node.putArray("enum") + allowed.forEach(values::add) + } + return node +} + +private fun integer( + description: String, + minimum: Long, + maximum: Long? = null, +): ObjectNode { + val node = property("integer", description) + node.put("minimum", minimum) + if (maximum != null) node.put("maximum", maximum) + return node +} + +private fun stringArray(description: String): ObjectNode { + val node = property("array", description) + node.putObject("items").put("type", "string") + return node +} + +private fun property( + type: String, + description: String, +): ObjectNode = + McpDispatcher.MAPPER + .createObjectNode() + .put("type", type) + .put("description", description) + +private fun ObjectNode.text(name: String): String? = get(name)?.asText() + +private fun ObjectNode.long(name: String): Long? = get(name)?.asLong() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt new file mode 100644 index 00000000..7fc0ccb5 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -0,0 +1,64 @@ +package net.rsprox.mcp.session + +import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.connection.ClientTypeDictionary +import net.rsprox.proxy.target.ProxyTargetConfig +import java.io.IOException +import java.net.ServerSocket + +/** Must be created right after [ProxyService.start], before anything else allocates a port. */ +internal class ProxyServiceLauncher( + private val service: ProxyService, + portSkip: Int, +) : ClientLauncher { + // The proxy keeps its first proxy port private, and its HTTP ports are offsets from it. + // The first allocation after start() returns that port. + private val basePort = service.allocatePort() + + init { + // Leaves the low ports to a GUI that shares the same configured range. + repeat(portSkip - 1) { service.allocatePort() } + } + + override fun targets(): List = service.proxyTargets + + override fun reserve(target: ProxyTargetConfig): Reservation { + // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. + var port: Int + do { + port = service.allocatePort() + } while (!isFree(port) || !isFree(HTTP_PORT_BASE + (port - basePort))) + val proxyTarget = service.initializeHttpServer(port, target) + return Reservation(port, proxyTarget.httpPort) { monitor -> + service.launchRuneLiteClient(monitor, null, port, proxyTarget) + // Probing leaves a window in which another process can take the port. The proxy registers + // a client type for a port only after it has bound it. + check(isBound(port)) { "proxy port $port could not be bound" } + } + } + + override fun kill(proxyPort: Int) { + service.killAliveProcess(proxyPort) + } + + private fun isBound(port: Int): Boolean = + try { + ClientTypeDictionary[port] + true + } catch (e: IllegalArgumentException) { + false + } + + private fun isFree(port: Int): Boolean = + try { + ServerSocket(port).close() + true + } catch (e: IOException) { + false + } + + private companion object { + // Mirrors net.rsprox.proxy.config.HTTP_SERVER_PORT, which is internal to the proxy module. + private const val HTTP_PORT_BASE = 43600 + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt new file mode 100644 index 00000000..ffa48385 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -0,0 +1,232 @@ +package net.rsprox.mcp.session + +import com.fasterxml.jackson.annotation.JsonInclude +import net.rsprox.mcp.bridge.BridgeLink +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketLog +import net.rsprox.proxy.target.ProxyTargetConfig +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** Stable for the life of the MCP process. Survives client restarts. Never a port. */ +@JvmInline +public value class SessionId( + public val value: String, +) { + override fun toString(): String = value +} + +/** One client process generation of a session. A restart makes a new launch on new ports. */ +public data class Launch( + val generation: Int, + val proxyPort: Int, + /** The identity the in-client bridge reports when it connects. */ + val httpPort: Int, + val startedAtMs: Long, +) + +public sealed interface ClientState { + public data class Stopped( + val reason: String, + ) : ClientState + + public data class Launching( + val launch: Launch, + ) : ClientState + + public data class Connected( + val launch: Launch, + val link: BridgeLink, + val pid: Long, + ) : ClientState +} + +/** Everything that can change a session's [ClientState]. Several threads raise these; [reduce] decides. */ +public sealed interface SessionEvent { + public data class Launched( + val launch: Launch, + ) : SessionEvent + + public data class Hello( + val httpPort: Int, + val link: BridgeLink, + val pid: Long, + ) : SessionEvent + + public data class LinkClosed( + val link: BridgeLink, + ) : SessionEvent + + public data class Stop( + val reason: String, + ) : SessionEvent +} + +/** Returns [state] itself when the event is stale or does not apply. */ +internal fun reduce( + state: ClientState, + event: SessionEvent, +): ClientState = + when (event) { + is SessionEvent.Launched -> + if (state is ClientState.Stopped) ClientState.Launching(event.launch) else state + is SessionEvent.Hello -> + if (state is ClientState.Launching && state.launch.httpPort == event.httpPort) { + ClientState.Connected(state.launch, event.link, event.pid) + } else { + state + } + is SessionEvent.LinkClosed -> + if (state is ClientState.Connected && state.link === event.link) { + ClientState.Stopped("client exited") + } else { + state + } + is SessionEvent.Stop -> + if (state is ClientState.Stopped) state else ClientState.Stopped(event.reason) + } + +public data class LoginInfo( + val epoch: Int, + val revision: Int, + val world: Int, + val name: String?, + val online: Boolean, + /** False while online means no decoder was hooked for this login, so its packets never reach the log. */ + val transcribing: Boolean, +) + +/** The newest login of a session. Updates from an older login are ignored. */ +internal class LoginRegistry { + private var epoch = 0 + private var info: LoginInfo? = null + + @Synchronized + fun nextEpoch(): Int = ++epoch + + @Synchronized + fun current(): LoginInfo? = info + + @Synchronized + fun login( + epoch: Int, + revision: Int, + world: Int, + ) { + val current = info + if (current != null && current.epoch > epoch) return + info = + if (current != null && current.epoch == epoch) { + current.copy(online = true) + } else { + LoginInfo(epoch, revision, world, name = null, online = true, transcribing = false) + } + } + + @Synchronized + fun update( + epoch: Int, + change: (LoginInfo) -> LoginInfo, + ) { + val current = info ?: return + if (current.epoch == epoch) info = change(current) + } +} + +/** What the session tools return. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public data class SessionSnapshot( + val session: String, + val target: String, + /** `stopped`, `launching` or `connected`. */ + val state: String, + val reason: String?, + val generation: Int?, + val proxyPort: Int?, + val httpPort: Int?, + val pid: Long?, + val login: LoginInfo?, + val cursor: Long, +) + +public class Session internal constructor( + public val id: SessionId, + public val target: ProxyTargetConfig, +) { + /** Owned by the session rather than a launch, so a restart keeps the records and the cursor space. */ + public val packets: PacketLog = PacketLog() + internal val logins = LoginRegistry() + private val lock = ReentrantLock() + private val changed = lock.newCondition() + private var generations = 0 + + @Volatile + public var client: ClientState = ClientState.Stopped("not started") + private set + + internal fun nextGeneration(): Int = lock.withLock { ++generations } + + /** The only writer of [client]. Every real transition leaves a marker in the packet log. */ + internal fun apply(event: SessionEvent): ClientState = + lock.withLock { + val before = client + val after = reduce(before, event) + if (after !== before) { + client = after + mark(after) + changed.signalAll() + } + after + } + + /** Blocks while the client is launching, for at most [waitMs]. Never throws on timeout. */ + internal fun awaitConnected(waitMs: Long): ClientState = + lock.withLock { + var remaining = TimeUnit.MILLISECONDS.toNanos(waitMs) + while (client is ClientState.Launching && remaining > 0) { + remaining = changed.awaitNanos(remaining) + } + client + } + + public fun snapshot(): SessionSnapshot { + val state = client + val launch = + when (state) { + is ClientState.Stopped -> null + is ClientState.Launching -> state.launch + is ClientState.Connected -> state.launch + } + return SessionSnapshot( + session = id.value, + target = target.name, + state = + when (state) { + is ClientState.Stopped -> "stopped" + is ClientState.Launching -> "launching" + is ClientState.Connected -> "connected" + }, + reason = (state as? ClientState.Stopped)?.reason, + generation = launch?.generation, + proxyPort = launch?.proxyPort, + httpPort = launch?.httpPort, + pid = (state as? ClientState.Connected)?.pid, + login = logins.current(), + cursor = packets.head().seq, + ) + } + + private fun mark(state: ClientState) { + val (prot, text) = + when (state) { + is ClientState.Launching -> + "CLIENT_LAUNCHED" to + "generation=${state.launch.generation} proxyPort=${state.launch.proxyPort} " + + "httpPort=${state.launch.httpPort}" + is ClientState.Connected -> "CLIENT_CONNECTED" to "pid=${state.pid}" + is ClientState.Stopped -> "CLIENT_EXITED" to state.reason + } + packets.append(logins.current()?.epoch ?: 0, 0, Origin.PROXY, prot, text) + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt new file mode 100644 index 00000000..32d8222d --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -0,0 +1,162 @@ +package net.rsprox.mcp.session + +import net.rsprox.mcp.packets.PacketTap +import net.rsprox.mcp.server.ToolError +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.settings.SettingSetStore +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicReference + +/** The part of the proxy a session needs. Narrow so the lifecycle can be exercised without a real client. */ +internal interface ClientLauncher { + fun targets(): List + + /** Picks free ports and binds the HTTP server of [target]. Throws when the target cannot be prepared. */ + fun reserve(target: ProxyTargetConfig): Reservation + + /** Kills the client on [proxyPort], if any, and releases the proxy state held for it. Idempotent. */ + fun kill(proxyPort: Int) +} + +internal class Reservation( + val proxyPort: Int, + val httpPort: Int, + /** Starts the client and blocks until its launcher has completed the handshake. Throws on failure. */ + val launch: (monitor: SessionMonitor) -> Unit, +) + +public class SessionManager internal constructor( + private val launcher: ClientLauncher, + private val settings: SettingSetStore, + private val launchTimeoutMs: Long = DEFAULT_LAUNCH_TIMEOUT_MS, +) { + private val sessions = CopyOnWriteArrayList() + + // The proxy's launch path is not re-entrant, so launches and stops take turns. + private val launchLock = Any() + private var hungLaunch = false + + /** + * Creates a session, or relaunches a stopped one. Calling it again for a session that is launching + * or connected launches nothing and only waits, so a caller can extend a wait that elapsed. + */ + public fun start( + target: String?, + session: String?, + waitMs: Long, + ): SessionSnapshot { + val started = + synchronized(launchLock) { + val existing = session?.let { resolve(it) } + if (existing != null && target != null && !existing.target.name.equals(target, ignoreCase = true)) { + throw ToolError("session ${existing.id} belongs to target '${existing.target.name}'") + } + val current = existing ?: Session(SessionId("s${sessions.size + 1}"), resolveTarget(target)) + if (current.client is ClientState.Stopped) launch(current) + current + } + started.awaitConnected(waitMs) + return started.snapshot() + } + + /** Kills the client. The session and its packets stay listed and readable. Idempotent. */ + public fun stop(session: String?): SessionSnapshot { + val resolved = resolve(session) + synchronized(launchLock) { + when (val state = resolved.client) { + is ClientState.Stopped -> {} + is ClientState.Launching -> launcher.kill(state.launch.proxyPort) + is ClientState.Connected -> { + state.link.close() + launcher.kill(state.launch.proxyPort) + } + } + resolved.apply(SessionEvent.Stop("stopped by caller")) + } + return resolved.snapshot() + } + + public fun list(): List = sessions.map { it.snapshot() } + + /** A null [ref] means the only session. */ + public fun resolve(ref: String?): Session { + if (ref != null) { + return sessions.firstOrNull { it.id.value == ref } + ?: throw ToolError("no session '$ref'. Sessions: ${sessionIds()}") + } + return sessions.singleOrNull() + ?: throw ToolError( + if (sessions.isEmpty()) { + "no session exists yet; call session_start first" + } else { + "several sessions exist; pass one of: ${sessionIds()}" + }, + ) + } + + public fun targets(): List = launcher.targets().map { it.name } + + private fun sessionIds(): String = sessions.joinToString(", ") { it.id.value }.ifEmpty { "none" } + + private fun resolveTarget(name: String?): ProxyTargetConfig { + val targets = launcher.targets() + if (name == null) { + // Target 0 is the official game; a custom target is what a headless caller is here to test. + return targets.firstOrNull { it.id != 0 } ?: targets.first() + } + return targets.firstOrNull { it.name.equals(name, ignoreCase = true) } + ?: throw ToolError("no target '$name'. Targets: ${targets.joinToString(", ") { it.name }}") + } + + private fun launch(session: Session) { + if (hungLaunch) { + throw ToolError("an earlier launch never completed its handshake; restart the rsprox MCP process") + } + val reservation = + try { + launcher.reserve(session.target) + } catch (e: Exception) { + throw ToolError("could not prepare target '${session.target.name}': ${rootMessage(e)}") + } + val launch = + Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort, System.currentTimeMillis()) + sessions.addIfAbsent(session) + session.apply(SessionEvent.Launched(launch)) + + // The proxy waits for the launcher's handshake with no timeout, so the wait is bounded from outside. + val failure = AtomicReference() + val thread = + Thread({ + try { + reservation.launch(PacketTap(session.packets, session.logins, settings)) + } catch (t: Throwable) { + failure.set(t) + } + }, "mcp-launch-${session.id}") + thread.isDaemon = true + thread.start() + thread.join(launchTimeoutMs) + val reason = + if (thread.isAlive) { + hungLaunch = true + "launcher never completed its handshake" + } else { + failure.get()?.let(::rootMessage) ?: return + } + launcher.kill(launch.proxyPort) + session.apply(SessionEvent.Stop(reason)) + throw ToolError("session ${session.id} failed to launch: $reason") + } + + private fun rootMessage(throwable: Throwable): String { + val root = generateSequence(throwable) { it.cause }.last() + return root.message ?: root.toString() + } + + private companion object { + // Covers a first run, where the launcher downloads the client before it handshakes. + private const val DEFAULT_LAUNCH_TIMEOUT_MS = 180_000L + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt new file mode 100644 index 00000000..41c7339e --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt @@ -0,0 +1,110 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.SessionManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ToolsTest { + private val mapper = McpDispatcher.MAPPER + private val manager = SessionManager(FakeLauncher(), TapSettingSetStore) + private val dispatcher = McpDispatcher(tools { manager }, "test") + + private fun call( + name: String, + arguments: String = "{}", + ): JsonNode { + val body = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$name","arguments":$arguments}}""" + return mapper.readTree(dispatcher.handle("POST", null, body).body).get("result") + } + + private fun text( + name: String, + arguments: String = "{}", + ): String { + val result = call(name, arguments) + assertFalse(result.get("isError").asBoolean(), result.toString()) + return result.get("content")[0].get("text").asText() + } + + @Test + fun `the four tools are listed`() { + val body = dispatcher.handle("POST", null, """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""").body + val names = mapper.readTree(body).get("result").get("tools").map { it.get("name").asText() } + assertEquals(listOf("session_start", "session_stop", "session_list", "packets_read"), names) + } + + @Test + fun `session start, list and stop report the session as compact json`() { + assertEquals( + """{"session":"s1","target":"My Server","state":"launching","generation":1,"proxyPort":43751,""" + + """"httpPort":43650,"cursor":1}""", + text("session_start", """{"target":"My Server"}"""), + ) + val list = mapper.readTree(text("session_list")) + assertEquals("s1", list.get("sessions").single().get("session").asText()) + assertEquals(listOf("Old School RuneScape", "My Server"), list.get("targets").map { it.asText() }) + + val stopped = mapper.readTree(text("session_stop")) + assertEquals("stopped", stopped.get("state").asText()) + assertEquals("stopped by caller", stopped.get("reason").asText()) + } + + @Test + fun `packets read prints a meta line and one line per packet`() { + text("session_start") + val log = manager.resolve("s1").packets + log.append(1, 48, Origin.CLIENT, "RESUME_P_STRINGDIALOG", "[resume_p_stringdialog] string=\"McpProto\"") + log.append(1, 49, Origin.SERVER, "PLAYER_INFO", "[player_info]\n [localplayer] index=1\n - move") + + assertEquals( + """ + {"next":3,"head":3,"dropped":0,"timedOut":false,"count":3} + 1 L0 T0 P CLIENT_LAUNCHED generation=1 proxyPort=43751 httpPort=43650 + 2 L1 T48 C RESUME_P_STRINGDIALOG [resume_p_stringdialog] string="McpProto" + 3 L1 T49 S PLAYER_INFO [player_info] + [localplayer] index=1 + - move + """.trimIndent(), + text("packets_read"), + ) + } + + @Test + fun `packets read applies its filters and cursor`() { + text("session_start") + val log = manager.resolve("s1").packets + log.append(1, 1, Origin.CLIENT, "IF_BUTTON", "[if_button] com=558:7") + log.append(1, 2, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"McpProto is available\"") + + val byProt = text("packets_read", """{"prots":["if_settext"]}""").lines() + assertEquals("""{"next":3,"head":3,"dropped":0,"timedOut":false,"count":1}""", byProt[0]) + assertTrue(byProt[1].startsWith("3 L1 T2 S IF_SETTEXT ")) + + assertEquals(2, text("packets_read", """{"origin":"client"}""").lines().size) + assertEquals(2, text("packets_read", """{"origin":"proxy","contains":"generation=1"}""").lines().size) + assertEquals(2, text("packets_read", """{"after":2}""").lines().size) + assertEquals(3, text("packets_read", """{"limit":2}""").lines().size) + + val waited = text("packets_read", """{"after":3,"wait_ms":20}""") + assertEquals("""{"next":3,"head":3,"dropped":0,"timedOut":true,"count":0}""", waited) + } + + @Test + fun `a tool call before any session exists tells the caller what to do`() { + val result = call("packets_read") + assertTrue(result.get("isError").asBoolean()) + assertEquals("no session exists yet; call session_start first", result.get("content")[0].get("text").asText()) + } + + @Test + fun `an origin outside the allowed set is refused`() { + text("session_start") + assertTrue(call("packets_read", """{"origin":"sideways"}""").get("isError").asBoolean()) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt new file mode 100644 index 00000000..ddf88c57 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt @@ -0,0 +1,164 @@ +package net.rsprox.mcp.session + +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.server.ToolError +import net.rsprox.proxy.target.ProxyTargetConfig +import java.util.concurrent.CountDownLatch +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +internal class FakeLauncher( + private val targets: List = listOf(target(0, "Old School RuneScape"), target(1, "My Server")), +) : ClientLauncher { + val reserved = ArrayList() + val killed = ArrayList() + var reserve: (ProxyTargetConfig) -> Unit = {} + var launch: () -> Unit = {} + + override fun targets(): List = targets + + override fun reserve(target: ProxyTargetConfig): Reservation { + reserve.invoke(target) + reserved += target + return Reservation(43750 + reserved.size, 43649 + reserved.size) { launch() } + } + + override fun kill(proxyPort: Int) { + killed += proxyPort + } +} + +class SessionManagerTest { + private val launcher = FakeLauncher() + private val manager = SessionManager(launcher, TapSettingSetStore, launchTimeoutMs = 200) + + @Test + fun `a first start launches the first custom target and stays launching`() { + val snapshot = manager.start(null, null, 0) + assertEquals("s1", snapshot.session) + assertEquals("My Server", snapshot.target) + assertEquals("launching", snapshot.state) + assertEquals(1, snapshot.generation) + assertEquals(43751, snapshot.proxyPort) + assertEquals(43650, snapshot.httpPort) + assertEquals(listOf(snapshot), manager.list()) + } + + @Test + fun `a target is chosen by name regardless of case`() { + assertEquals("Old School RuneScape", manager.start("old school runescape", null, 0).target) + } + + @Test + fun `an unknown target names the known ones and creates no session`() { + val error = assertFailsWith { manager.start("Nowhere", null, 0) } + assertEquals("no target 'Nowhere'. Targets: Old School RuneScape, My Server", error.message) + assertEquals(emptyList(), manager.list()) + } + + @Test + fun `starting a running session again launches nothing`() { + manager.start(null, null, 0) + val again = manager.start(null, "s1", 0) + assertEquals(1, again.generation) + assertEquals(1, launcher.reserved.size) + } + + @Test + fun `stop kills the client and a later start relaunches the same session on new ports`() { + manager.start(null, null, 0) + val stopped = manager.stop("s1") + assertEquals("stopped", stopped.state) + assertEquals("stopped by caller", stopped.reason) + assertEquals(listOf(43751), launcher.killed) + + val relaunched = manager.start(null, "s1", 0) + assertEquals("s1", relaunched.session) + assertEquals("launching", relaunched.state) + assertEquals(2, relaunched.generation) + assertEquals(43752, relaunched.proxyPort) + assertEquals( + listOf("CLIENT_LAUNCHED", "CLIENT_EXITED", "CLIENT_LAUNCHED"), + manager.resolve("s1").packets.read(PacketQuery()).packets.map { it.prot }, + ) + } + + @Test + fun `stopping a stopped session changes nothing`() { + manager.start(null, null, 0) + val first = manager.stop(null) + val second = manager.stop(null) + assertEquals(first, second) + assertEquals(listOf(43751), launcher.killed) + } + + @Test + fun `a session cannot be restarted on another target`() { + manager.start("My Server", null, 0) + val error = assertFailsWith { manager.start("Old School RuneScape", "s1", 0) } + assertEquals("session s1 belongs to target 'My Server'", error.message) + } + + @Test + fun `a target that cannot be prepared fails without creating a session`() { + launcher.reserve = { throw RuntimeException("wrapper", IllegalStateException("connection refused")) } + val error = assertFailsWith { manager.start(null, null, 0) } + assertEquals("could not prepare target 'My Server': connection refused", error.message) + assertEquals(emptyList(), manager.list()) + } + + @Test + fun `a launch that fails stops the session with the cause and can be retried`() { + launcher.launch = { throw IllegalStateException("proxy port 43751 could not be bound") } + val error = assertFailsWith { manager.start(null, null, 0) } + assertEquals("session s1 failed to launch: proxy port 43751 could not be bound", error.message) + val stopped = manager.list().single() + assertEquals("stopped", stopped.state) + assertEquals("proxy port 43751 could not be bound", stopped.reason) + assertEquals(listOf(43751), launcher.killed) + + launcher.launch = {} + val retried = manager.start(null, "s1", 0) + assertEquals("launching", retried.state) + assertEquals(2, retried.generation) + } + + @Test + fun `a launch that never returns is abandoned and blocks further launches`() { + val release = CountDownLatch(1) + launcher.launch = { release.await() } + try { + val error = assertFailsWith { manager.start(null, null, 0) } + assertEquals("session s1 failed to launch: launcher never completed its handshake", error.message) + assertEquals("stopped", manager.list().single().state) + assertEquals(listOf(43751), launcher.killed) + + launcher.launch = {} + for (session in listOf("s1", null)) { + val refused = assertFailsWith { manager.start(null, session, 0) } + assertTrue(refused.message!!.contains("restart the rsprox MCP process"), refused.message) + } + assertEquals(1, launcher.reserved.size) + } finally { + release.countDown() + } + } + + @Test + fun `an omitted session resolves only when exactly one exists`() { + val none = assertFailsWith { manager.resolve(null) } + assertEquals("no session exists yet; call session_start first", none.message) + + manager.start(null, null, 0) + assertEquals("s1", manager.resolve(null).id.value) + + manager.start(null, null, 0) + val several = assertFailsWith { manager.resolve(null) } + assertEquals("several sessions exist; pass one of: s1, s2", several.message) + val unknown = assertFailsWith { manager.resolve("s9") } + assertEquals("no session 's9'. Sessions: s1, s2", unknown.message) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt new file mode 100644 index 00000000..af055ea4 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt @@ -0,0 +1,169 @@ +package net.rsprox.mcp.session + +import net.rsprox.mcp.bridge.BridgeLink +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.session.ClientState.Connected +import net.rsprox.mcp.session.ClientState.Launching +import net.rsprox.mcp.session.ClientState.Stopped +import net.rsprox.mcp.session.SessionEvent.Hello +import net.rsprox.mcp.session.SessionEvent.Launched +import net.rsprox.mcp.session.SessionEvent.LinkClosed +import net.rsprox.mcp.session.SessionEvent.Stop +import net.rsprox.proxy.target.ProxyTargetConfig +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal fun target( + id: Int, + name: String, +): ProxyTargetConfig = + ProxyTargetConfig( + id = id, + name = name, + javConfigUrl = "http://127.0.0.1/jav_config.ws", + modulus = null, + varpCount = 5000, + revision = null, + runeliteBootstrapUrl = null, + runeliteBootstrapCommitHash = null, + runeliteGamepackUrl = null, + binaryFolder = null, + ) + +class SessionTest { + private val first = Launch(generation = 1, proxyPort = 43751, httpPort = 43650, startedAtMs = 0) + private val second = Launch(generation = 2, proxyPort = 43752, httpPort = 43651, startedAtMs = 0) + private val link = BridgeLink() + + @Test + fun `a launch starts only from stopped`() { + assertEquals(Launching(first), reduce(Stopped("not started"), Launched(first))) + assertEquals(Launching(first), reduce(Launching(first), Launched(second))) + val connected = Connected(first, link, pid = 7) + assertEquals(connected, reduce(connected, Launched(second))) + } + + @Test + fun `a hello for the current launch connects it`() { + assertEquals( + Connected(first, link, pid = 7), + reduce(Launching(first), Hello(first.httpPort, link, pid = 7)), + ) + } + + @Test + fun `a hello from an earlier launch is dropped`() { + assertEquals(Launching(second), reduce(Launching(second), Hello(first.httpPort, link, pid = 7))) + assertEquals(Stopped("stopped by caller"), reduce(Stopped("stopped by caller"), Hello(first.httpPort, link, 7))) + val connected = Connected(first, link, pid = 7) + assertEquals(connected, reduce(connected, Hello(first.httpPort, BridgeLink(), pid = 8))) + } + + @Test + fun `closing the current link stops the session`() { + assertEquals(Stopped("client exited"), reduce(Connected(first, link, pid = 7), LinkClosed(link))) + } + + @Test + fun `a close from an earlier link is dropped`() { + val relaunched = Connected(second, BridgeLink(), pid = 9) + assertEquals(relaunched, reduce(relaunched, LinkClosed(link))) + assertEquals(Launching(second), reduce(Launching(second), LinkClosed(link))) + assertEquals(Stopped("stopped by caller"), reduce(Stopped("stopped by caller"), LinkClosed(link))) + } + + @Test + fun `stop applies from every running state and keeps the first reason when repeated`() { + assertEquals(Stopped("stopped by caller"), reduce(Launching(first), Stop("stopped by caller"))) + assertEquals(Stopped("stopped by caller"), reduce(Connected(first, link, 7), Stop("stopped by caller"))) + val once = reduce(Launching(first), Stop("stopped by caller")) + assertEquals(once, reduce(once, Stop("again"))) + } + + @Test + fun `each transition leaves one marker in the packet log and dropped events leave none`() { + val session = Session(SessionId("s1"), target(1, "My Server")) + session.apply(Launched(first)) + session.apply(Launched(second)) + session.apply(Hello(first.httpPort, link, pid = 7)) + session.apply(Stop("stopped by caller")) + session.apply(Stop("again")) + + val markers = session.packets.read(PacketQuery()).packets + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED", "CLIENT_EXITED"), markers.map { it.prot }) + assertEquals("generation=1 proxyPort=43751 httpPort=43650", markers[0].text) + assertEquals("pid=7", markers[1].text) + assertEquals("stopped by caller", markers[2].text) + } + + @Test + fun `the snapshot describes the current state`() { + val session = Session(SessionId("s1"), target(1, "My Server")) + val stopped = session.snapshot() + assertEquals("stopped", stopped.state) + assertEquals("not started", stopped.reason) + assertNull(stopped.proxyPort) + assertEquals(0, stopped.cursor) + + session.apply(Launched(first)) + val launching = session.snapshot() + assertEquals("launching", launching.state) + assertNull(launching.reason) + assertEquals(1, launching.generation) + assertEquals(43751, launching.proxyPort) + assertEquals(43650, launching.httpPort) + assertNull(launching.pid) + assertEquals(1, launching.cursor) + + session.apply(Hello(first.httpPort, link, pid = 7)) + val connected = session.snapshot() + assertEquals("connected", connected.state) + assertEquals(7, connected.pid) + assertEquals("My Server", connected.target) + } + + @Test + fun `awaiting a launching session returns at the deadline without throwing`() { + val session = Session(SessionId("s1"), target(1, "My Server")) + session.apply(Launched(first)) + assertEquals(Launching(first), session.awaitConnected(30)) + assertEquals(Launching(first), session.awaitConnected(0)) + } + + @Test + fun `awaiting a launching session returns when it connects`() { + val session = Session(SessionId("s1"), target(1, "My Server")) + session.apply(Launched(first)) + val waiter = CompletableFuture.supplyAsync { session.awaitConnected(30_000) } + session.apply(Hello(first.httpPort, link, pid = 7)) + assertEquals(Connected(first, link, pid = 7), waiter.get(10, TimeUnit.SECONDS)) + } + + @Test + fun `an update from an earlier login does not change the current login`() { + val logins = LoginRegistry() + val old = logins.nextEpoch() + logins.login(old, revision = 241, world = 1) + logins.update(old) { it.copy(name = "first") } + val new = logins.nextEpoch() + logins.login(new, revision = 241, world = 2) + + logins.update(old) { it.copy(online = false, name = "stale") } + logins.login(old, revision = 241, world = 1) + + assertEquals(LoginInfo(new, 241, 2, name = null, online = true, transcribing = false), logins.current()) + } + + @Test + fun `a reconnect within one login keeps what is known about it`() { + val logins = LoginRegistry() + val epoch = logins.nextEpoch() + logins.login(epoch, revision = 241, world = 1) + logins.update(epoch) { it.copy(name = "tester", transcribing = true, online = false) } + logins.login(epoch, revision = 241, world = 1) + assertEquals(LoginInfo(epoch, 241, 1, name = "tester", online = true, transcribing = true), logins.current()) + } +} From 604a45bf34afefc66a8cb4c31920d724b3a900ec Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 15:57:15 +0200 Subject: [PATCH 05/72] feat(mcp): add the in-client bridge plugin A sideloaded RuneLite plugin that dials the rsprox MCP server and answers its requests: state, widgets, vars, screenshot, click, type and login. It stays dormant, with no thread and no listener, unless a rendezvous file exists and the server welcomes the client. The jar holds only the plugin's own classes. A build check fails when anything else ends up in it. Co-Authored-By: Claude Opus 5.5 --- runelite/rsprox-mcp-bridge/build.gradle.kts | 56 ++ .../rsprox/mcpbridge/BridgeConnection.java | 201 +++++++ .../net/rsprox/mcpbridge/BridgeException.java | 11 + .../java/net/rsprox/mcpbridge/GameAccess.java | 103 ++++ .../net/rsprox/mcpbridge/McpBridgePlugin.java | 46 ++ .../main/java/net/rsprox/mcpbridge/Ops.java | 489 ++++++++++++++++++ 6 files changed, 906 insertions(+) create mode 100644 runelite/rsprox-mcp-bridge/build.gradle.kts create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java diff --git a/runelite/rsprox-mcp-bridge/build.gradle.kts b/runelite/rsprox-mcp-bridge/build.gradle.kts new file mode 100644 index 00000000..6dba0826 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/build.gradle.kts @@ -0,0 +1,56 @@ +import java.util.zip.ZipFile + +plugins { + id("java") +} + +repositories { + maven { + url = uri("https://repo.runelite.net") + content { + includeGroupByRegex("net\\.runelite.*") + } + } + mavenCentral() +} + +dependencies { + // The client provides its API, Gson, javax.inject and slf4j at runtime; the jar bundles nothing. + compileOnly(libs.runelite.client) +} + +tasks.withType().configureEach { + options.encoding = "UTF-8" + options.release.set(11) +} + +tasks.jar { + manifest { + attributes("Implementation-Version" to project.version) + } +} + +// The root build applies the Kotlin plugin to every subproject. A sideloaded plugin gets its own class +// loader, so anything that leaks into this jar would shadow or duplicate what the client already has. +val verifyJarContents by tasks.registering { + val jar = tasks.jar.flatMap { it.archiveFile } + inputs.file(jar) + doLast { + val own = "net/rsprox/mcpbridge/" + val foreign = + ZipFile(jar.get().asFile).use { zip -> + zip + .entries() + .asSequence() + .map { it.name } + .filterNot { it.startsWith(own) || own.startsWith(it) } + .filterNot { it == "META-INF/" || it == "META-INF/MANIFEST.MF" } + .toList() + } + check(foreign.isEmpty()) { "The bridge plugin jar must hold only its own classes, but found: $foreign" } + } +} + +tasks.check { + dependsOn(verifyJarContents) +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java new file mode 100644 index 00000000..efc5a1ea --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -0,0 +1,201 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.client.RuneLiteProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The socket to rsprox: one JSON object per line in each direction. A reader thread hands each request + * to a small worker pool, so a request that waits on the game never holds up the next one. + */ +final class BridgeConnection implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(BridgeConnection.class); + private static final int PROTOCOL = 1; + private static final int CONNECT_TIMEOUT_MS = 2_000; + private static final int HELLO_TIMEOUT_MS = 5_000; + private static final int WORKERS = 4; + private static final String JAV_CONFIG_PREFIX = "--jav_config=http://127.0.0.1:"; + + private final Socket socket; + private final BufferedReader reader; + private final BufferedWriter writer; + private final Gson gson; + private final Ops ops; + private final AtomicBoolean closed = new AtomicBoolean(); + private ExecutorService workers; + + private BridgeConnection(Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops) { + this.socket = socket; + this.reader = reader; + this.writer = writer; + this.gson = gson; + this.ops = ops; + } + + /** + * Connects to the rsprox that launched this client. Returns null, having started no thread, when + * there is no rsprox to talk to or it does not expect this client. + */ + static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { + if (!Files.isRegularFile(rendezvous)) { + return null; + } + int httpPort = httpPortFromCommandLine(); + if (httpPort < 0) { + return null; + } + Socket socket = new Socket(); + try { + JsonObject file = gson.fromJson(Files.readString(rendezvous), JsonObject.class); + socket.connect( + new InetSocketAddress(InetAddress.getLoopbackAddress(), file.get("port").getAsInt()), + CONNECT_TIMEOUT_MS); + socket.setSoTimeout(HELLO_TIMEOUT_MS); + BufferedReader reader = + new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + BufferedWriter writer = + new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); + + JsonObject hello = new JsonObject(); + hello.addProperty("hello", PROTOCOL); + hello.addProperty("httpPort", httpPort); + hello.add("token", file.get("token")); + hello.addProperty("pid", ProcessHandle.current().pid()); + hello.addProperty("runelite", RuneLiteProperties.getVersion()); + hello.addProperty("plugin", BridgeConnection.class.getPackage().getImplementationVersion()); + writer.write(gson.toJson(hello)); + writer.write('\n'); + writer.flush(); + + String line = reader.readLine(); + JsonObject answer = line == null ? null : gson.fromJson(line, JsonObject.class); + if (answer == null || !answer.has("welcome")) { + log.info("rsprox MCP bridge stays dormant: {}", answer == null ? "no answer" : answer.get("reject")); + socket.close(); + return null; + } + socket.setSoTimeout(0); + BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops); + connection.start(); + log.info("rsprox MCP bridge connected as session {}", answer.get("session")); + return connection; + } catch (IOException | RuntimeException e) { + log.info("rsprox MCP bridge stays dormant: {}", e.toString()); + try { + socket.close(); + } catch (IOException ignored) { + // Nothing was established, so there is nothing to report. + } + return null; + } + } + + /** The port in the client's jav_config argument, which is how rsprox tells its clients apart. */ + private static int httpPortFromCommandLine() { + String command = System.getProperty("sun.java.command", ""); + int start = command.indexOf(JAV_CONFIG_PREFIX); + if (start < 0) { + return -1; + } + start += JAV_CONFIG_PREFIX.length(); + int end = start; + while (end < command.length() && Character.isDigit(command.charAt(end))) { + end++; + } + try { + return Integer.parseInt(command.substring(start, end)); + } catch (NumberFormatException e) { + return -1; + } + } + + private void start() { + workers = Executors.newFixedThreadPool(WORKERS, runnable -> daemon(runnable, "rsprox-mcp-bridge-worker")); + daemon(this::readLoop, "rsprox-mcp-bridge-reader").start(); + } + + private static Thread daemon(Runnable runnable, String name) { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; + } + + private void readLoop() { + try { + String line; + while ((line = reader.readLine()) != null) { + JsonObject request = gson.fromJson(line, JsonObject.class); + long id = request.get("id").getAsLong(); + String op = request.get("op").getAsString(); + JsonObject args = request.has("args") ? request.getAsJsonObject("args") : new JsonObject(); + workers.execute(() -> answer(id, op, args)); + } + } catch (IOException | RuntimeException e) { + if (!closed.get()) { + log.info("rsprox MCP bridge lost its connection: {}", e.toString()); + } + } finally { + close(); + } + } + + private void answer(long id, String op, JsonObject args) { + JsonObject reply = new JsonObject(); + reply.addProperty("id", id); + try { + reply.add("ok", ops.run(op, args)); + } catch (BridgeException e) { + reply.add("err", error(e.code, e.getMessage())); + } catch (Throwable t) { + log.warn("rsprox MCP bridge op {} failed", op, t); + reply.add("err", error("internal", t.toString())); + } + try { + synchronized (writer) { + writer.write(gson.toJson(reply)); + writer.write('\n'); + writer.flush(); + } + } catch (IOException e) { + close(); + } + } + + private static JsonObject error(String code, String message) { + JsonObject error = new JsonObject(); + error.addProperty("code", code); + error.addProperty("message", message); + return error; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + try { + socket.close(); + } catch (IOException ignored) { + // The connection is being dropped either way. + } + workers.shutdownNow(); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java new file mode 100644 index 00000000..aba42756 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java @@ -0,0 +1,11 @@ +package net.rsprox.mcpbridge; + +/** A failure reported to rsprox as {@code err}. Codes: bad_args, not_found, wrong_state, timeout, internal. */ +final class BridgeException extends Exception { + final String code; + + BridgeException(String code, String message) { + super(message); + this.code = code; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java new file mode 100644 index 00000000..f9d52e2a --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java @@ -0,0 +1,103 @@ +package net.rsprox.mcpbridge; + +import java.awt.Canvas; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import javax.swing.SwingUtilities; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.ui.DrawManager; + +/** + * The only holder of the client, its canvas and the draw manager. Each is handed out solely inside a + * callback that runs on the thread it belongs to, so an op cannot touch game state from a worker thread. + */ +final class GameAccess { + private static final long TIMEOUT_MS = 5_000; + + private final Client client; + private final ClientThread clientThread; + private final DrawManager drawManager; + + GameAccess(Client client, ClientThread clientThread, DrawManager drawManager) { + this.client = client; + this.clientThread = clientThread; + this.drawManager = drawManager; + } + + interface ClientCall { + T call(Client client) throws BridgeException; + } + + /** Runs {@code body} on the client thread and waits for its result. */ + T read(ClientCall body) throws BridgeException { + CompletableFuture result = new CompletableFuture<>(); + clientThread.invoke(() -> { + try { + result.complete(body.call(client)); + } catch (Throwable t) { + result.completeExceptionally(t); + } + }); + return await(result, "the client thread"); + } + + /** Runs {@code body} on the AWT event thread, where synthetic input events must be dispatched. */ + void input(Consumer body) throws BridgeException { + try { + SwingUtilities.invokeAndWait(() -> body.accept(client.getCanvas())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while dispatching input"); + } catch (InvocationTargetException e) { + throw new BridgeException("internal", String.valueOf(e.getCause())); + } + } + + /** A copy of the next rendered frame, at whatever size the renderer produced it. */ + BufferedImage frame() throws BridgeException { + CompletableFuture result = new CompletableFuture<>(); + drawManager.requestNextFrameListener(image -> { + try { + // Copied on the render thread: the renderer may reuse the image for the next frame. + result.complete(copy(image)); + } catch (Throwable t) { + result.completeExceptionally(t); + } + }); + return await(result, "a rendered frame"); + } + + private static BufferedImage copy(Image image) { + BufferedImage copy = + new BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = copy.createGraphics(); + graphics.drawImage(image, 0, 0, null); + graphics.dispose(); + return copy; + } + + private static T await(CompletableFuture result, String what) throws BridgeException { + try { + return result.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + throw new BridgeException("timeout", "no answer from " + what + " within " + TIMEOUT_MS + " ms"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while waiting for " + what); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof BridgeException) { + throw (BridgeException) cause; + } + throw new BridgeException("internal", String.valueOf(cause)); + } + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java new file mode 100644 index 00000000..0bb44103 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -0,0 +1,46 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.Gson; +import java.nio.file.Path; +import java.nio.file.Paths; +import javax.inject.Inject; +import net.runelite.api.Client; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.ui.DrawManager; + +/** + * The in-client half of the rsprox MCP server. The sideload directory is shared with clients that + * rsprox did not launch for an agent, so the plugin does nothing unless a running MCP server left a + * rendezvous file and welcomes this client. + */ +@PluginDescriptor( + name = "RSProx MCP Bridge", + description = "Lets the rsprox MCP server observe and drive this client", + hidden = true +) +public class McpBridgePlugin extends Plugin { + @Inject private Client client; + @Inject private ClientThread clientThread; + @Inject private DrawManager drawManager; + @Inject private Gson gson; + + private BridgeConnection connection; + + @Override + protected void startUp() { + Path rendezvous = Paths.get(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"); + Ops ops = new Ops(new GameAccess(client, clientThread, drawManager)); + // The protocol spells out an absent player as null, which the client's Gson would drop. + connection = BridgeConnection.dial(rendezvous, gson.newBuilder().serializeNulls().create(), ops); + } + + @Override + protected void shutDown() { + if (connection != null) { + connection.close(); + connection = null; + } + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java new file mode 100644 index 00000000..69a580c9 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -0,0 +1,489 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import java.awt.Canvas; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.imageio.ImageIO; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.MenuEntry; +import net.runelite.api.Player; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.Widget; + +/** The operations rsprox can ask for, by name. Coordinates are canvas units everywhere. */ +final class Ops { + private static final Pattern WIDGET_REF = Pattern.compile("(\\d+):(\\d+)(?:\\[(\\d+)])?"); + private static final int WIDGET_DEPTH_LIMIT = 12; + private static final int DEFAULT_WIDGET_LIMIT = 200; + private static final int DEFAULT_LOGIN_WAIT_MS = 15_000; + private static final int LOGIN_POLL_MS = 100; + + interface Op { + JsonElement run(JsonObject args) throws BridgeException; + } + + private final GameAccess game; + private final Map table = new HashMap<>(); + + Ops(GameAccess game) { + this.game = game; + table.put("state", this::state); + table.put("widgets", this::widgets); + table.put("vars", this::vars); + table.put("screenshot", this::screenshot); + table.put("click", this::click); + table.put("type", this::type); + table.put("login", this::login); + } + + JsonElement run(String op, JsonObject args) throws BridgeException { + Op handler = table.get(op); + if (handler == null) { + throw new BridgeException("bad_args", "unknown op '" + op + "'"); + } + return handler.run(args); + } + + private JsonElement state(JsonObject args) throws BridgeException { + return game.read(client -> { + JsonObject out = new JsonObject(); + out.addProperty("gameState", client.getGameState().name()); + out.addProperty("tick", client.getTickCount()); + Canvas canvas = client.getCanvas(); + out.add("canvas", ints(canvas.getWidth(), canvas.getHeight())); + out.addProperty("world", client.getWorld()); + Player player = client.getLocalPlayer(); + if (player == null) { + out.add("player", JsonNull.INSTANCE); + } else { + WorldPoint at = player.getWorldLocation(); + JsonObject described = new JsonObject(); + described.addProperty("name", player.getName()); + described.addProperty("x", at.getX()); + described.addProperty("y", at.getY()); + described.addProperty("plane", at.getPlane()); + out.add("player", described); + } + JsonObject menu = new JsonObject(); + menu.addProperty("open", client.isMenuOpen()); + JsonArray entries = new JsonArray(); + for (MenuEntry entry : client.getMenu().getMenuEntries()) { + JsonObject described = new JsonObject(); + described.addProperty("option", entry.getOption()); + described.addProperty("target", entry.getTarget()); + described.addProperty("type", entry.getType().name()); + described.addProperty("id", entry.getIdentifier()); + described.addProperty("p0", entry.getParam0()); + described.addProperty("p1", entry.getParam1()); + entries.add(described); + } + menu.add("entries", entries); + out.add("menu", menu); + return out; + }); + } + + private JsonElement widgets(JsonObject args) throws BridgeException { + Integer group = optionalInt(args, "group"); + String text = optionalString(args, "text"); + String needle = text == null ? null : text.toLowerCase(Locale.ROOT); + boolean includeHidden = optionalBoolean(args, "hidden"); + Integer limit = optionalInt(args, "limit"); + return game.read(client -> { + WidgetWalk walk = + new WidgetWalk(group, needle, includeHidden, limit == null ? DEFAULT_WIDGET_LIMIT : limit); + Set roots = new LinkedHashSet<>(); + for (Widget root : client.getWidgetRoots()) { + if (root != null) { + roots.add(root.getId() >>> 16); + walk.visit(root, 0); + } + } + JsonObject out = new JsonObject(); + JsonArray rootGroups = new JsonArray(); + for (int root : roots) { + rootGroups.add(root); + } + out.add("roots", rootGroups); + out.add("widgets", walk.found); + out.addProperty("truncated", walk.truncated); + return out; + }); + } + + /** Lists the widgets that have something to read or click: text, a name or actions. */ + private static final class WidgetWalk { + final JsonArray found = new JsonArray(); + boolean truncated; + private final Integer group; + private final String needle; + private final boolean includeHidden; + private final int limit; + + WidgetWalk(Integer group, String needle, boolean includeHidden, int limit) { + this.group = group; + this.needle = needle; + this.includeHidden = includeHidden; + this.limit = limit; + } + + void visit(Widget widget, int depth) { + if (widget == null || truncated || depth > WIDGET_DEPTH_LIMIT) { + return; + } + boolean hidden = widget.isHidden(); + if (hidden && !includeHidden) { + return; + } + if (group == null || widget.getId() >>> 16 == group) { + JsonObject described = describe(widget, hidden); + if (described != null) { + if (found.size() >= limit) { + truncated = true; + return; + } + found.add(described); + } + } + visitAll(widget.getStaticChildren(), depth); + visitAll(widget.getDynamicChildren(), depth); + visitAll(widget.getNestedChildren(), depth); + } + + private void visitAll(Widget[] children, int depth) { + if (children != null) { + for (Widget child : children) { + visit(child, depth + 1); + } + } + } + + private JsonObject describe(Widget widget, boolean hidden) { + String text = nullToEmpty(widget.getText()); + String name = nullToEmpty(widget.getName()); + JsonArray actions = new JsonArray(); + String[] raw = widget.getActions(); + if (raw != null) { + for (String action : raw) { + if (action != null && !action.isEmpty()) { + actions.add(action); + } + } + } + if (text.isEmpty() && name.isEmpty() && actions.size() == 0) { + return null; + } + if (needle != null && !contains(text) && !contains(name) && !contains(actions.toString())) { + return null; + } + Rectangle bounds = widget.getBounds(); + JsonObject out = new JsonObject(); + out.addProperty("id", ref(widget)); + out.addProperty("text", text); + out.addProperty("name", name); + out.add("actions", actions); + out.add("bounds", ints(bounds.x, bounds.y, bounds.width, bounds.height)); + out.add("click", ints(centre(bounds).x, centre(bounds).y)); + out.addProperty("type", widget.getType()); + out.addProperty("hidden", hidden); + return out; + } + + private boolean contains(String haystack) { + return haystack.toLowerCase(Locale.ROOT).contains(needle); + } + } + + private JsonElement vars(JsonObject args) throws BridgeException { + int[] varps = optionalInts(args, "varps"); + int[] varbits = optionalInts(args, "varbits"); + int[] varcInts = optionalInts(args, "varcInts"); + int[] varcStrs = optionalInts(args, "varcStrs"); + return game.read(client -> { + JsonObject out = new JsonObject(); + out.add("varps", readVars("varp", varps, id -> number(client.getVarpValue(id)))); + out.add("varbits", readVars("varbit", varbits, id -> number(client.getVarbitValue(id)))); + out.add("varcInts", readVars("varcInt", varcInts, id -> number(client.getVarcIntValue(id)))); + out.add("varcStrs", readVars("varcStr", varcStrs, id -> string(client.getVarcStrValue(id)))); + return out; + }); + } + + private interface VarReader { + JsonElement read(int id); + } + + private static JsonObject readVars(String kind, int[] ids, VarReader reader) throws BridgeException { + JsonObject out = new JsonObject(); + for (int id : ids) { + try { + out.add(Integer.toString(id), reader.read(id)); + } catch (RuntimeException e) { + // The client indexes its tables with the id and throws for one that does not exist. + throw new BridgeException("not_found", kind + " " + id + " could not be read: " + e); + } + } + return out; + } + + private JsonElement screenshot(JsonObject args) throws BridgeException { + BufferedImage frame = game.frame(); + int[] canvas = game.read(client -> new int[] {client.getCanvas().getWidth(), client.getCanvas().getHeight()}); + int width = canvas[0]; + int height = canvas[1]; + if (width <= 0 || height <= 0) { + throw new BridgeException("wrong_state", "the canvas has no size yet"); + } + // A high-density display renders more pixels than the canvas has units. Clicks are in canvas + // units, so the image is brought to canvas size to make a pixel and a click coordinate the same thing. + BufferedImage sized = frame; + if (frame.getWidth() != width || frame.getHeight() != height) { + sized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = sized.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + graphics.drawImage(frame, 0, 0, width, height, null); + graphics.dispose(); + } + ByteArrayOutputStream png = new ByteArrayOutputStream(); + try { + ImageIO.write(sized, "png", png); + } catch (IOException e) { + throw new BridgeException("internal", "could not encode the frame: " + e); + } + JsonObject out = new JsonObject(); + out.addProperty("png", Base64.getEncoder().encodeToString(png.toByteArray())); + out.addProperty("width", width); + out.addProperty("height", height); + return out; + } + + private JsonElement click(JsonObject args) throws BridgeException { + String button = optionalString(args, "button"); + boolean right = "right".equals(button); + if (button != null && !right && !"left".equals(button)) { + throw new BridgeException("bad_args", "button must be \"left\" or \"right\""); + } + String widget = optionalString(args, "widget"); + Integer x = optionalInt(args, "x"); + Integer y = optionalInt(args, "y"); + Point point; + if (widget != null) { + point = game.read(client -> centre(visibleBounds(client, widget))); + } else if (x != null && y != null) { + point = new Point(x, y); + } else { + throw new BridgeException("bad_args", "pass either x and y, or widget"); + } + int awtButton = right ? MouseEvent.BUTTON3 : MouseEvent.BUTTON1; + int downMask = right ? InputEvent.BUTTON3_DOWN_MASK : InputEvent.BUTTON1_DOWN_MASK; + game.input(canvas -> { + long now = System.currentTimeMillis(); + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, now, 0, point.x, point.y, 0, false)); + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, now, downMask, point.x, point.y, 1, false, awtButton)); + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_RELEASED, now + 30, 0, point.x, point.y, 1, false, awtButton)); + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_CLICKED, now + 30, 0, point.x, point.y, 1, false, awtButton)); + }); + JsonObject out = new JsonObject(); + out.addProperty("x", point.x); + out.addProperty("y", point.y); + return out; + } + + private static Rectangle visibleBounds(Client client, String ref) throws BridgeException { + Matcher matcher = WIDGET_REF.matcher(ref); + if (!matcher.matches()) { + throw new BridgeException("bad_args", "widget must look like \"558:7\" or \"558:7[3]\""); + } + Widget widget = client.getWidget(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); + if (widget != null && matcher.group(3) != null) { + widget = widget.getChild(Integer.parseInt(matcher.group(3))); + } + if (widget == null) { + throw new BridgeException("not_found", "widget " + ref + " does not exist"); + } + Rectangle bounds = widget.getBounds(); + if (widget.isHidden() || bounds == null || bounds.isEmpty()) { + throw new BridgeException("not_found", "widget " + ref + " is not visible"); + } + return bounds; + } + + private JsonElement type(JsonObject args) throws BridgeException { + String text = optionalString(args, "text"); + if (text == null) { + throw new BridgeException("bad_args", "text is required"); + } + boolean enter = optionalBoolean(args, "enter"); + game.input(canvas -> { + for (char ch : text.toCharArray()) { + key(canvas, KeyEvent.getExtendedKeyCodeForChar(ch), ch); + } + if (enter) { + key(canvas, KeyEvent.VK_ENTER, '\n'); + } + }); + JsonObject out = new JsonObject(); + out.addProperty("typed", text.length()); + return out; + } + + private static void key(Canvas canvas, int code, char ch) { + long now = System.currentTimeMillis(); + canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_PRESSED, now, 0, code, ch)); + canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_TYPED, now, 0, KeyEvent.VK_UNDEFINED, ch)); + canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_RELEASED, now, 0, code, ch)); + } + + private JsonElement login(JsonObject args) throws BridgeException { + String username = optionalString(args, "username"); + if (username == null) { + throw new BridgeException("bad_args", "username is required"); + } + String password = optionalString(args, "password"); + Integer wait = optionalInt(args, "wait_ms"); + long deadline = System.currentTimeMillis() + (wait == null ? DEFAULT_LOGIN_WAIT_MS : wait); + game.read(client -> { + GameState state = client.getGameState(); + if (state != GameState.LOGIN_SCREEN) { + throw new BridgeException("wrong_state", "the client is not on the login screen: " + state); + } + client.setUsername(username); + client.setPassword(password == null ? "" : password); + client.setGameState(GameState.LOGGING_IN); + return null; + }); + while (true) { + GameState state = game.read(Client::getGameState); + if (state == GameState.LOGGED_IN) { + JsonObject out = new JsonObject(); + out.addProperty("gameState", state.name()); + return out; + } + if (state == GameState.LOGIN_SCREEN) { + throw new BridgeException("wrong_state", "the login was refused; the client is back on the login screen"); + } + if (System.currentTimeMillis() >= deadline) { + throw new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); + } + try { + Thread.sleep(LOGIN_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while waiting for the login"); + } + } + } + + private static Point centre(Rectangle bounds) { + return new Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + } + + private static String ref(Widget widget) { + String ref = (widget.getId() >>> 16) + ":" + (widget.getId() & 0xFFFF); + return widget.getIndex() >= 0 ? ref + "[" + widget.getIndex() + "]" : ref; + } + + private static JsonArray ints(int... values) { + JsonArray out = new JsonArray(); + for (int value : values) { + out.add(value); + } + return out; + } + + private static JsonElement number(int value) { + return new JsonPrimitive(value); + } + + private static JsonElement string(String value) { + return value == null ? JsonNull.INSTANCE : new JsonPrimitive(value); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private static JsonElement present(JsonObject args, String name) { + JsonElement value = args.get(name); + return value == null || value.isJsonNull() ? null : value; + } + + private static Integer optionalInt(JsonObject args, String name) throws BridgeException { + JsonElement value = present(args, name); + if (value == null) { + return null; + } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { + throw new BridgeException("bad_args", name + " must be an integer"); + } + return value.getAsInt(); + } + + private static String optionalString(JsonObject args, String name) throws BridgeException { + JsonElement value = present(args, name); + if (value == null) { + return null; + } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { + throw new BridgeException("bad_args", name + " must be a string"); + } + return value.getAsString(); + } + + private static boolean optionalBoolean(JsonObject args, String name) throws BridgeException { + JsonElement value = present(args, name); + if (value == null) { + return false; + } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { + throw new BridgeException("bad_args", name + " must be a boolean"); + } + return value.getAsBoolean(); + } + + private static int[] optionalInts(JsonObject args, String name) throws BridgeException { + JsonElement value = present(args, name); + if (value == null) { + return new int[0]; + } + if (!value.isJsonArray()) { + throw new BridgeException("bad_args", name + " must be an array of integers"); + } + JsonArray array = value.getAsJsonArray(); + int[] out = new int[array.size()]; + for (int i = 0; i < out.length; i++) { + JsonElement item = array.get(i); + if (!item.isJsonPrimitive() || !item.getAsJsonPrimitive().isNumber()) { + throw new BridgeException("bad_args", name + " must be an array of integers"); + } + out[i] = item.getAsInt(); + } + return out; + } +} From e120caa01ee952f9b32f25a99514894d05c76852 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:00:23 +0200 Subject: [PATCH 06/72] feat(mcp): add the bridge hub, link and plugin jar installer The hub listens on an ephemeral loopback port and publishes it, with a token, in a private rendezvous file. A plugin that dials in is routed to the session that expects its HTTP port, or rejected. A link carries newline-delimited JSON with correlation ids, so calls may overlap. A closed connection fails the pending calls and is reported once. The plugin jar is embedded as a resource and installed into the sideload directory of a target when its content differs. Co-Authored-By: Claude Opus 5.5 --- mcp/build.gradle.kts | 13 ++ .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 170 ++++++++++++++ .../kotlin/net/rsprox/mcp/bridge/BridgeJar.kt | 47 ++++ .../net/rsprox/mcp/bridge/BridgeLink.kt | 138 +++++++++++- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 213 ++++++++++++++++++ .../net/rsprox/mcp/bridge/BridgeJarTest.kt | 78 +++++++ .../net/rsprox/mcp/bridge/FakePlugin.kt | 126 +++++++++++ .../net/rsprox/mcp/session/SessionTest.kt | 8 +- 8 files changed, 786 insertions(+), 7 deletions(-) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt diff --git a/mcp/build.gradle.kts b/mcp/build.gradle.kts index a141bb42..e11d0af3 100644 --- a/mcp/build.gradle.kts +++ b/mcp/build.gradle.kts @@ -1,4 +1,11 @@ +// Resolves to the plugin's plain jar. It is shipped as a resource and installed before each launch. +val bridgePlugin: Configuration by configurations.creating { + isCanBeConsumed = false + isTransitive = false +} + dependencies { + bridgePlugin(project(":runelite:rsprox-mcp-bridge")) implementation(projects.proxy) implementation(projects.shared) implementation(projects.cache.cacheApi) @@ -9,6 +16,12 @@ dependencies { implementation(rootProject.libs.inline.logger) } +tasks.processResources { + from(bridgePlugin) { + rename { "rsprox-mcp-bridge.jar" } + } +} + tasks.register("run") { environment("APP_VERSION", project.version) group = "run" diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt new file mode 100644 index 00000000..a59058c5 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -0,0 +1,170 @@ +package net.rsprox.mcp.bridge + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.michaelbull.logging.InlineLogger +import java.io.IOException +import java.io.Writer +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap + +/** What a session wants to hear about the client it is waiting for. */ +internal interface BridgeListener { + /** The session id, told to the plugin for its log. */ + val session: String + + /** Returns false when the session no longer waits for this client; the hub then drops the link. */ + fun onHello( + link: BridgeLink, + pid: Long, + ): Boolean + + fun onClosed(link: BridgeLink) +} + +/** + * Where in-client bridge plugins connect. The plugin dials in: it finds the port and a token in the + * rendezvous file and names the HTTP port of its launch, which routes it to the session that expects it. + * A client this process did not launch is rejected and stays dormant. + */ +public class BridgeHub( + private val rendezvous: Path, +) : AutoCloseable { + private val expected = ConcurrentHashMap() + private val links = ConcurrentHashMap.newKeySet() + private val token = newToken() + + @Volatile + private var server: ServerSocket? = null + + /** Binds an ephemeral loopback port and publishes it, with the token, in the rendezvous file. */ + public fun start() { + val server = ServerSocket(0, BACKLOG, InetAddress.getLoopbackAddress()) + this.server = server + writeRendezvous(server.localPort) + val thread = Thread({ acceptLoop(server) }, "mcp-bridge-accept") + thread.isDaemon = true + thread.start() + } + + /** Routes the hello that names [httpPort] to [listener]. Must be called before the client is forked. */ + internal fun expect( + httpPort: Int, + listener: BridgeListener, + ) { + expected[httpPort] = listener + } + + override fun close() { + Files.deleteIfExists(rendezvous) + server?.close() + links.forEach { it.close() } + } + + private fun writeRendezvous(port: Int) { + val content = + MAPPER + .createObjectNode() + .put("protocol", PROTOCOL) + .put("port", port) + .put("token", token) + .put("pid", ProcessHandle.current().pid()) + Files.createDirectories(rendezvous.parent) + // Written beside the target and moved into place, so a plugin never reads a partial file + // and the token is never in a file that others may read. + val temp = Files.createTempFile(rendezvous.parent, "bridge", ".tmp") + if (Files.getFileStore(temp).supportsFileAttributeView("posix")) { + Files.setPosixFilePermissions(temp, PosixFilePermissions.fromString("rw-------")) + } + Files.write(temp, MAPPER.writeValueAsBytes(content)) + Files.move(temp, rendezvous, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } + + private fun acceptLoop(server: ServerSocket) { + while (true) { + val socket = + try { + server.accept() + } catch (e: IOException) { + return + } + try { + greet(socket) + } catch (e: Exception) { + logger.debug(e) { "Dropped a bridge connection that did not complete its hello" } + socket.close() + } + } + } + + private fun greet(socket: Socket) { + socket.soTimeout = HELLO_TIMEOUT_MS + val reader = socket.getInputStream().bufferedReader(Charsets.UTF_8) + val writer = socket.getOutputStream().bufferedWriter(Charsets.UTF_8) + val hello = MAPPER.readTree(reader.readLine() ?: throw IOException("closed before hello")) + val httpPort = hello.get("httpPort")?.asInt() ?: -1 + val refusal = + when { + !tokenMatches(hello.get("token")) -> "bad token" + hello.get("hello")?.asInt() != PROTOCOL -> + "bridge protocol ${hello.get("hello")} is not supported; the installed plugin jar is stale" + else -> null + } + val listener = if (refusal == null) expected.remove(httpPort) else null + if (listener == null) { + writer.line(MAPPER.createObjectNode().put("reject", refusal ?: "no session expects httpPort $httpPort")) + socket.close() + return + } + socket.soTimeout = 0 + val link = + BridgeLink(socket, reader, writer) { closed -> + links.remove(closed) + listener.onClosed(closed) + } + // The welcome goes out before the session can see the link, so no request can overtake it. + writer.line(MAPPER.createObjectNode().put("welcome", PROTOCOL).put("session", listener.session)) + if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) { + socket.close() + return + } + links += link + link.start() + logger.info { "Bridge connected for session ${listener.session} (httpPort $httpPort)" } + } + + private fun tokenMatches(candidate: JsonNode?): Boolean = + candidate != null && + candidate.isTextual && + MessageDigest.isEqual(candidate.asText().toByteArray(), token.toByteArray()) + + private fun Writer.line(message: JsonNode) { + write(MAPPER.writeValueAsString(message)) + write("\n") + flush() + } + + private companion object { + private val logger = InlineLogger() + private val MAPPER: ObjectMapper = jacksonObjectMapper() + private const val PROTOCOL = 1 + private const val BACKLOG = 16 + private const val HELLO_TIMEOUT_MS = 5_000 + + private fun newToken(): String { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt new file mode 100644 index 00000000..e4dedc88 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt @@ -0,0 +1,47 @@ +package net.rsprox.mcp.bridge + +import net.rsprox.proxy.target.ProxyTargetConfig +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption + +/** Puts the bridge plugin jar that this build embeds where the client of a target sideloads plugins from. */ +public class BridgeJar internal constructor( + private val overrideDir: Path?, + private val home: Path, + private val embedded: () -> ByteArray, +) { + public constructor(overrideDir: Path?) : this( + overrideDir, + Path.of(System.getProperty("user.home")), + { + val resource = + BridgeJar::class.java.getResourceAsStream("/$FILE_NAME") + ?: error("The bridge plugin jar is missing from this build") + resource.use { it.readAllBytes() } + }, + ) + + /** + * Installs the jar unless the installed one already has the same content. A client that has the + * old jar open keeps reading it, because the new one is moved into place rather than written over it. + */ + public fun installFor(target: ProxyTargetConfig): Path { + // Custom targets run a client that keeps its files apart from those of the official one. + val dir = overrideDir ?: home.resolve(if (target.id == 0) ".runelite" else ".rlcustom").resolve(SIDELOAD_DIR) + val installed = dir.resolve(FILE_NAME) + val wanted = embedded() + if (Files.isRegularFile(installed) && Files.readAllBytes(installed).contentEquals(wanted)) return installed + Files.createDirectories(dir) + // The client loads every `.jar` in the directory, so the partial file must not look like one. + val temp = Files.createTempFile(dir, FILE_NAME, ".tmp") + Files.write(temp, wanted) + Files.move(temp, installed, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + return installed + } + + private companion object { + private const val FILE_NAME = "rsprox-mcp-bridge.jar" + private const val SIDELOAD_DIR = "sideloaded-plugins" + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt index a8d13cd2..e125855a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -1,6 +1,138 @@ package net.rsprox.mcp.bridge -/** One connected in-client bridge. Compared by identity, so a late event from an old link cannot match a new one. */ -public class BridgeLink internal constructor() { - internal fun close() {} +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.io.BufferedReader +import java.io.Closeable +import java.io.IOException +import java.io.Writer +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * A failed bridge call. [code] is one the plugin reports (`bad_args`, `not_found`, `wrong_state`, + * `timeout`, `internal`) or `closed` when the client went away. + */ +public class BridgeError( + public val code: String, + message: String, +) : Exception(message) + +/** + * One connected in-client bridge. Compared by identity, so a late event from an old link cannot match a new one. + * + * The wire carries one JSON object per line in each direction. Requests may overlap; each reply is + * matched to its caller by id. + */ +public class BridgeLink internal constructor( + private val socket: Closeable, + private val reader: BufferedReader, + private val writer: Writer, + private val onClosed: (BridgeLink) -> Unit, +) { + private val pending = ConcurrentHashMap>() + private val ids = AtomicLong() + private val closed = AtomicBoolean() + + internal fun start() { + val thread = Thread(::readLoop, "mcp-bridge-reader") + thread.isDaemon = true + thread.start() + } + + /** + * Sends one request and blocks the calling thread for its reply. + * + * @return the `ok` value of the reply + * @throws BridgeError when the plugin answers `err`, when no reply arrives within [timeoutMs] + * (code `timeout`), or when the link is or becomes closed (code `closed`) + */ + public fun call( + op: String, + args: ObjectNode, + timeoutMs: Long = DEFAULT_TIMEOUT_MS, + ): JsonNode { + val id = ids.incrementAndGet() + val reply = CompletableFuture() + pending[id] = reply + try { + // Checked after registering: a close that happens from here on fails this call through `pending`. + if (closed.get()) throw closedError() + val request = MAPPER.createObjectNode().put("id", id).put("op", op) + request.set("args", args) + try { + send(MAPPER.writeValueAsString(request)) + } catch (e: IOException) { + close() + throw closedError() + } + return reply.get(timeoutMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + throw BridgeError("timeout", "the client did not answer '$op' within $timeoutMs ms") + } catch (e: ExecutionException) { + throw e.cause ?: e + } finally { + pending.remove(id) + } + } + + /** Drops the connection. The reader then fails the pending calls and reports the close. */ + internal fun close() { + try { + socket.close() + } catch (e: IOException) { + // The link is being dropped either way. + } + } + + @Synchronized + private fun send(line: String) { + writer.write(line) + writer.write("\n") + writer.flush() + } + + private fun readLoop() { + try { + while (true) { + val line = reader.readLine() ?: break + val message = MAPPER.readTree(line) + val id = message?.get("id")?.asLong() ?: continue + val reply = pending[id] ?: continue + val err = message.get("err") + if (err != null) { + val code = err.get("code")?.asText() ?: "internal" + reply.completeExceptionally(BridgeError(code, err.get("message")?.asText().orEmpty())) + } else { + reply.complete(message.get("ok") ?: MAPPER.nullNode()) + } + } + } catch (e: IOException) { + // Falls through to the close below, like an orderly end of stream. + } catch (e: JsonProcessingException) { + // A peer that stops speaking the protocol cannot be resynchronised. + } finally { + closed.set(true) + close() + for (reply in pending.values) { + reply.completeExceptionally(closedError()) + } + onClosed(this) + } + } + + private fun closedError(): BridgeError = BridgeError("closed", "the client is no longer connected") + + private companion object { + private val MAPPER: ObjectMapper = jacksonObjectMapper() + private const val DEFAULT_TIMEOUT_MS = 10_000L + } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt new file mode 100644 index 00000000..2477d5ec --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -0,0 +1,213 @@ +package net.rsprox.mcp.bridge + +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class BridgeHubTest { + private val mapper = jacksonObjectMapper() + private val fixture = TestHub() + private val hub = fixture.hub + private val plugins = ArrayList() + + @AfterTest + fun cleanUp() { + plugins.forEach { it.close() } + fixture.close() + } + + private fun dial( + httpPort: Int, + token: String? = null, + protocol: Int = 1, + ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, token, protocol).also { plugins += it } + + private fun args(json: String = "{}"): ObjectNode = mapper.readTree(json) as ObjectNode + + /** A welcomed plugin and the link the hub made for it. */ + private fun connect(listener: RecordingListener = RecordingListener()): Pair { + hub.expect(43650, listener) + val plugin = dial(43650) + assertEquals(1, plugin.read()?.get("welcome")?.asInt()) + return plugin to listener.hello.get(10, TimeUnit.SECONDS).first + } + + private fun async(body: () -> T): CompletableFuture = CompletableFuture.supplyAsync(body) + + private fun bridgeError(call: CompletableFuture<*>): BridgeError { + val failure = assertFailsWith { call.get(10, TimeUnit.SECONDS) } + return failure.cause as BridgeError + } + + @Test + fun `the rendezvous file names the port and token, is private, and is deleted on close`() { + val file = mapper.readTree(Files.readAllBytes(fixture.rendezvous)) + assertEquals(1, file.get("protocol").asInt()) + assertTrue(file.get("port").asInt() > 0) + assertEquals(22, file.get("token").asText().length) + assertEquals(ProcessHandle.current().pid(), file.get("pid").asLong()) + if (Files.getFileStore(fixture.rendezvous).supportsFileAttributeView("posix")) { + assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(fixture.rendezvous))) + } + + hub.close() + assertFalse(Files.exists(fixture.rendezvous)) + } + + @Test + fun `each hub has its own token`() { + TestHub().use { other -> + val mine = mapper.readTree(Files.readAllBytes(fixture.rendezvous)).get("token") + val theirs = mapper.readTree(Files.readAllBytes(other.rendezvous)).get("token") + assertFalse(mine == theirs) + } + } + + @Test + fun `a hello for an expected port is welcomed and handed to its listener`() { + val listener = RecordingListener(session = "s3") + hub.expect(43650, listener) + val plugin = dial(43650) + + val welcome = plugin.read()!! + assertEquals(1, welcome.get("welcome").asInt()) + assertEquals("s3", welcome.get("session").asText()) + assertEquals(4242, listener.hello.get(10, TimeUnit.SECONDS).second) + } + + @Test + fun `a wrong token is rejected and leaves the expectation in place`() { + val listener = RecordingListener() + hub.expect(43650, listener) + + val intruder = dial(43650, token = "not-the-token") + assertEquals("bad token", intruder.read()?.get("reject")?.asText()) + assertNull(intruder.read()) + assertFalse(listener.hello.isDone) + + assertEquals(1, dial(43650).read()?.get("welcome")?.asInt()) + } + + @Test + fun `a port that no session expects is rejected`() { + hub.expect(43650, RecordingListener()) + val stranger = dial(43600) + assertEquals("no session expects httpPort 43600", stranger.read()?.get("reject")?.asText()) + assertNull(stranger.read()) + } + + @Test + fun `a port is expected only once`() { + connect() + assertEquals("no session expects httpPort 43650", dial(43650).read()?.get("reject")?.asText()) + } + + @Test + fun `another protocol version is rejected as a stale jar`() { + val listener = RecordingListener() + hub.expect(43650, listener) + val reject = dial(43650, protocol = 2).read()?.get("reject")?.asText() + assertEquals("bridge protocol 2 is not supported; the installed plugin jar is stale", reject) + assertFalse(listener.hello.isDone) + } + + @Test + fun `a listener that no longer wants the client has its connection dropped`() { + val listener = RecordingListener(accept = false) + hub.expect(43650, listener) + val plugin = dial(43650) + assertEquals(1, plugin.read()?.get("welcome")?.asInt()) + assertNull(plugin.read()) + assertFalse(listener.closed.isDone) + } + + @Test + fun `a call sends the op and its args and returns the ok value`() { + val (plugin, link) = connect() + val call = async { link.call("click", args("""{"x":380,"y":215}""")) } + + val request = plugin.read()!! + assertEquals("click", request.get("op").asText()) + assertEquals(args("""{"x":380,"y":215}"""), request.get("args")) + plugin.send("""{"id":${request.get("id")},"ok":{"x":380,"y":215}}""") + + assertEquals(args("""{"x":380,"y":215}"""), call.get(10, TimeUnit.SECONDS)) + } + + @Test + fun `overlapping calls each receive their own reply, whatever the order of the answers`() { + val (plugin, link) = connect() + val slow = async { link.call("screenshot", args()) } + val first = plugin.read()!! + val fast = async { link.call("state", args()) } + val second = plugin.read()!! + assertEquals(listOf("screenshot", "state"), listOf(first.get("op").asText(), second.get("op").asText())) + + plugin.send("""{"id":${second.get("id")},"ok":{"gameState":"LOGIN_SCREEN"}}""") + assertEquals("LOGIN_SCREEN", fast.get(10, TimeUnit.SECONDS).get("gameState").asText()) + assertFalse(slow.isDone) + + plugin.send("""{"id":${first.get("id")},"ok":{"width":976}}""") + assertEquals(976, slow.get(10, TimeUnit.SECONDS).get("width").asInt()) + } + + @Test + fun `an err reply becomes a bridge error with its code and message`() { + val (plugin, link) = connect() + plugin.serve { _, _ -> """"err":{"code":"not_found","message":"widget 558:7 is not visible"}""" } + + val error = assertFailsWith { link.call("click", args("""{"widget":"558:7"}""")) } + assertEquals("not_found", error.code) + assertEquals("widget 558:7 is not visible", error.message) + } + + @Test + fun `a call that gets no reply times out and the link stays usable`() { + val (plugin, link) = connect() + val error = assertFailsWith { link.call("state", args(), timeoutMs = 50) } + assertEquals("timeout", error.code) + assertEquals("the client did not answer 'state' within 50 ms", error.message) + + val late = plugin.read()!! + plugin.send("""{"id":${late.get("id")},"ok":{}}""") + plugin.serve { _, _ -> """"ok":{"tick":7}""" } + assertEquals(7, link.call("state", args()).get("tick").asInt()) + } + + @Test + fun `a closed connection fails the pending calls, reports the close and refuses new calls`() { + val listener = RecordingListener() + val (plugin, link) = connect(listener) + val pending = async { link.call("screenshot", args()) } + plugin.read() + + plugin.close() + + assertEquals("closed", bridgeError(pending).code) + assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) + assertEquals("closed", assertFailsWith { link.call("state", args()) }.code) + } + + @Test + fun `closing the hub drops its links and stops accepting`() { + val listener = RecordingListener() + val (plugin, link) = connect(listener) + + hub.close() + + assertNull(plugin.read()) + assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt new file mode 100644 index 00000000..a848299f --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt @@ -0,0 +1,78 @@ +package net.rsprox.mcp.bridge + +import net.rsprox.mcp.session.target +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.FileTime +import java.util.zip.ZipFile +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class BridgeJarTest { + private val home: Path = Files.createTempDirectory("mcp-jar-test") + private var embedded = byteArrayOf(1, 2, 3) + private val custom = target(1, "My Server") + private val longAgo = FileTime.fromMillis(1_000_000) + + private fun jar(overrideDir: Path? = null) = BridgeJar(overrideDir, home) { embedded } + + @AfterTest + fun cleanUp() { + home.toFile().deleteRecursively() + } + + @Test + fun `a custom target gets the jar in the custom client's sideload directory`() { + val installed = jar().installFor(custom) + assertEquals(home.resolve(".rlcustom/sideloaded-plugins/rsprox-mcp-bridge.jar"), installed) + assertContentEquals(embedded, Files.readAllBytes(installed)) + } + + @Test + fun `the official target gets the jar in the official client's sideload directory`() { + val installed = jar().installFor(target(0, "Old School RuneScape")) + assertEquals(home.resolve(".runelite/sideloaded-plugins/rsprox-mcp-bridge.jar"), installed) + assertContentEquals(embedded, Files.readAllBytes(installed)) + } + + @Test + fun `an override directory is used for every target and leaves the defaults alone`() { + val dir = home.resolve("elsewhere") + assertEquals(dir.resolve("rsprox-mcp-bridge.jar"), jar(dir).installFor(custom)) + assertEquals(dir.resolve("rsprox-mcp-bridge.jar"), jar(dir).installFor(target(0, "Old School RuneScape"))) + assertEquals(listOf("elsewhere"), home.toFile().list()?.toList()) + } + + @Test + fun `an identical installed jar is left untouched`() { + val installed = jar().installFor(custom) + Files.setLastModifiedTime(installed, longAgo) + + jar().installFor(custom) + + assertEquals(longAgo, Files.getLastModifiedTime(installed)) + } + + @Test + fun `a different installed jar is replaced and nothing else is left in the directory`() { + val installed = jar().installFor(custom) + Files.setLastModifiedTime(installed, longAgo) + embedded = byteArrayOf(9, 9, 9, 9) + + jar().installFor(custom) + + assertContentEquals(embedded, Files.readAllBytes(installed)) + assertEquals(1, Files.list(installed.parent).use { it.count() }) + } + + @Test + fun `the build embeds the plugin jar`() { + val installed = BridgeJar(home.resolve("embedded")).installFor(custom) + ZipFile(installed.toFile()).use { zip -> + assertNotNull(zip.getEntry("net/rsprox/mcpbridge/McpBridgePlugin.class")) + } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt new file mode 100644 index 00000000..edbdddb7 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -0,0 +1,126 @@ +package net.rsprox.mcp.bridge + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.io.BufferedReader +import java.io.Closeable +import java.io.IOException +import java.io.StringReader +import java.io.StringWriter +import java.net.InetAddress +import java.net.Socket +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import kotlin.concurrent.thread + +private val mapper = jacksonObjectMapper() + +/** A link that is connected to nothing, for tests that only need its identity. */ +internal fun idleLink(): BridgeLink = BridgeLink(Closeable {}, BufferedReader(StringReader("")), StringWriter()) {} + +/** Polls until [condition] holds, so a test fails with a message instead of hanging. */ +internal fun awaitTrue( + what: String, + condition: () -> Boolean, +) { + val deadline = System.nanoTime() + 10_000_000_000 + while (!condition()) { + check(System.nanoTime() < deadline) { "timed out waiting until $what" } + Thread.sleep(5) + } +} + +/** A started hub whose rendezvous file lives in a temporary directory. */ +internal class TestHub : AutoCloseable { + val rendezvous: Path = Files.createTempDirectory("mcp-bridge-test").resolve("mcp").resolve("bridge.json") + val hub = BridgeHub(rendezvous).also { it.start() } + + override fun close() { + hub.close() + rendezvous.parent.parent + .toFile() + .deleteRecursively() + } +} + +internal class RecordingListener( + override val session: String = "s1", + private val accept: Boolean = true, +) : BridgeListener { + val hello = CompletableFuture>() + val closed = CompletableFuture() + + override fun onHello( + link: BridgeLink, + pid: Long, + ): Boolean { + hello.complete(link to pid) + return accept + } + + override fun onClosed(link: BridgeLink) { + closed.complete(link) + } +} + +/** The plugin's side of the wire, driven by the test. */ +internal class FakePlugin private constructor( + private val socket: Socket, +) : AutoCloseable { + private val reader = socket.getInputStream().bufferedReader() + private val writer = socket.getOutputStream().bufferedWriter() + + /** Null when the hub closed the connection. */ + fun read(): JsonNode? = reader.readLine()?.let(mapper::readTree) + + @Synchronized + fun send(json: String) { + writer.write(json) + writer.write("\n") + writer.flush() + } + + /** Answers every request with the `"ok":...` or `"err":...` member that [answer] returns. */ + fun serve(answer: (op: String, args: JsonNode) -> String) { + thread(isDaemon = true) { + try { + while (true) { + val request = read() ?: break + send("""{"id":${request.get("id")},${answer(request.get("op").asText(), request.get("args"))}}""") + } + } catch (e: IOException) { + // The test closed the connection. + } + } + } + + override fun close() { + socket.close() + } + + companion object { + /** Connects and says hello the way the plugin does. The hub's answer is the next [read]. */ + fun dial( + rendezvous: Path, + httpPort: Int, + token: String? = null, + protocol: Int = 1, + pid: Long = 4242, + ): FakePlugin { + val file = mapper.readTree(Files.readAllBytes(rendezvous)) + val socket = Socket(InetAddress.getLoopbackAddress(), file.get("port").asInt()) + socket.soTimeout = 10_000 + val plugin = FakePlugin(socket) + val hello = + mapper + .createObjectNode() + .put("hello", protocol) + .put("httpPort", httpPort) + .put("token", token ?: file.get("token").asText()) + .put("pid", pid) + plugin.send(mapper.writeValueAsString(hello)) + return plugin + } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt index af055ea4..968ec08c 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt @@ -1,6 +1,6 @@ package net.rsprox.mcp.session -import net.rsprox.mcp.bridge.BridgeLink +import net.rsprox.mcp.bridge.idleLink import net.rsprox.mcp.packets.PacketQuery import net.rsprox.mcp.session.ClientState.Connected import net.rsprox.mcp.session.ClientState.Launching @@ -36,7 +36,7 @@ internal fun target( class SessionTest { private val first = Launch(generation = 1, proxyPort = 43751, httpPort = 43650, startedAtMs = 0) private val second = Launch(generation = 2, proxyPort = 43752, httpPort = 43651, startedAtMs = 0) - private val link = BridgeLink() + private val link = idleLink() @Test fun `a launch starts only from stopped`() { @@ -59,7 +59,7 @@ class SessionTest { assertEquals(Launching(second), reduce(Launching(second), Hello(first.httpPort, link, pid = 7))) assertEquals(Stopped("stopped by caller"), reduce(Stopped("stopped by caller"), Hello(first.httpPort, link, 7))) val connected = Connected(first, link, pid = 7) - assertEquals(connected, reduce(connected, Hello(first.httpPort, BridgeLink(), pid = 8))) + assertEquals(connected, reduce(connected, Hello(first.httpPort, idleLink(), pid = 8))) } @Test @@ -69,7 +69,7 @@ class SessionTest { @Test fun `a close from an earlier link is dropped`() { - val relaunched = Connected(second, BridgeLink(), pid = 9) + val relaunched = Connected(second, idleLink(), pid = 9) assertEquals(relaunched, reduce(relaunched, LinkClosed(link))) assertEquals(Launching(second), reduce(Launching(second), LinkClosed(link))) assertEquals(Stopped("stopped by caller"), reduce(Stopped("stopped by caller"), LinkClosed(link))) From 6849906e6c012ca3fe7fde9b0776ffe27dd6da77 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:01:27 +0200 Subject: [PATCH 07/72] feat(mcp): connect sessions to their client through the bridge A launch registers its HTTP port with the hub before the client is forked. The plugin's hello moves the session to connected with the client pid, and a closed link moves it to stopped with reason "client exited" and releases the proxy-side state for the port. session_start and --start now wait up to 180 s for the hello. The plugin jar is installed while a target is prepared, and --sideload-dir overrides where. Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 18 ++- .../kotlin/net/rsprox/mcp/server/Tools.kt | 14 ++- .../mcp/session/ProxyServiceLauncher.kt | 3 + .../kotlin/net/rsprox/mcp/session/Session.kt | 11 ++ .../net/rsprox/mcp/session/SessionManager.kt | 41 ++++++- .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 18 ++- .../rsprox/mcp/session/SessionManagerTest.kt | 111 +++++++++++++++++- .../net/rsprox/mcp/session/SessionTest.kt | 24 ++++ 8 files changed, 221 insertions(+), 19 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 22a613ca..87cb5ac1 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -4,8 +4,11 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.int +import com.github.ajalt.clikt.parameters.types.path import com.github.michaelbull.logging.InlineLogger import io.netty.buffer.ByteBufAllocator +import net.rsprox.mcp.bridge.BridgeHub +import net.rsprox.mcp.bridge.BridgeJar import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.packets.UnfilteredFilterSetStore import net.rsprox.mcp.server.McpHttpServer @@ -14,6 +17,7 @@ import net.rsprox.mcp.server.tools import net.rsprox.mcp.session.ProxyServiceLauncher import net.rsprox.mcp.session.SessionManager import net.rsprox.proxy.ProxyService +import java.nio.file.Path import java.util.Locale import java.util.concurrent.atomic.AtomicReference @@ -24,6 +28,10 @@ public class McpCommand : CliktCommand(name = "mcp") { "--port-skip", help = "Proxy ports to leave unused at the start of the range, for a GUI running at the same time", ).int().default(50) + private val sideloadDir by option( + "--sideload-dir", + help = "Directory the client sideloads plugins from, when it is not the default of the target", + ).path() private val autostart by option("--start", help = "Target name to launch immediately") override fun run() { @@ -45,14 +53,18 @@ public class McpCommand : CliktCommand(name = "mcp") { } service.filterSetStore = UnfilteredFilterSetStore service.settingsStore = TapSettingSetStore - val launcher = ProxyServiceLauncher(service, portSkip.coerceAtLeast(1)) - val manager = SessionManager(launcher, service.settingsStore) + val launcher = ProxyServiceLauncher(service, portSkip.coerceAtLeast(1), BridgeJar(sideloadDir)) + val hub = BridgeHub(Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json")) + hub.start() + // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. + Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) + val manager = SessionManager(launcher, service.settingsStore, hub) sessions.set(manager) logger.info { "Ready. Targets: ${manager.targets().joinToString(", ")}" } autostart?.let { target -> try { - manager.start(target, null, 0) + manager.start(target, null, SessionManager.DEFAULT_WAIT_MS) } catch (e: ToolError) { logger.error { "Unable to start '$target': ${e.message}" } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 1b92ce0b..1f3079ef 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -18,22 +18,24 @@ public fun tools(sessions: () -> SessionManager): List = name = "session_start", description = "Launch a RuneLite client through rsprox, or relaunch a stopped session. " + - "Returns once the client's launcher has started. With no arguments it starts a new session " + - "on the first custom target. Pass `session` to relaunch a stopped session on fresh ports; " + - "its packet log and cursor continue. Calling it for a session that is already running " + - "launches nothing.", + "Blocks until the in-client bridge connects and then reports `state` as `connected`, " + + "after which the client_* tools work. If `wait_ms` elapses first, `state` is `launching`; " + + "call it again with the same `session` to keep waiting. With no arguments it starts a new " + + "session on the first custom target. Pass `session` to relaunch a stopped session on fresh " + + "ports; its packet log and cursor continue. Calling it for a session that is already " + + "running launches nothing.", inputSchema = schema( "target" to string("Proxy target name, as listed by session_list. Example: \"My Server\"."), "session" to string("Existing session id, such as \"s1\"."), - "wait_ms" to integer("How long to wait for the session to connect. Default 0.", 0, 600_000), + "wait_ms" to integer("How long to wait for the session to connect. Default 180000.", 0, 600_000), ), ) { args -> ToolResult.Json( sessions().start( target = args.text("target"), session = args.text("session"), - waitMs = args.long("wait_ms") ?: 0, + waitMs = args.long("wait_ms") ?: SessionManager.DEFAULT_WAIT_MS, ), ) }, diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index 7fc0ccb5..c14956cd 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -1,5 +1,6 @@ package net.rsprox.mcp.session +import net.rsprox.mcp.bridge.BridgeJar import net.rsprox.proxy.ProxyService import net.rsprox.proxy.connection.ClientTypeDictionary import net.rsprox.proxy.target.ProxyTargetConfig @@ -10,6 +11,7 @@ import java.net.ServerSocket internal class ProxyServiceLauncher( private val service: ProxyService, portSkip: Int, + private val bridgeJar: BridgeJar, ) : ClientLauncher { // The proxy keeps its first proxy port private, and its HTTP ports are offsets from it. // The first allocation after start() returns that port. @@ -23,6 +25,7 @@ internal class ProxyServiceLauncher( override fun targets(): List = service.proxyTargets override fun reserve(target: ProxyTargetConfig): Reservation { + bridgeJar.installFor(target) // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. var port: Int do { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index ffa48385..74e12ce1 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude import net.rsprox.mcp.bridge.BridgeLink import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.PacketLog +import net.rsprox.mcp.server.ToolError import net.rsprox.proxy.target.ProxyTargetConfig import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock @@ -190,6 +191,16 @@ public class Session internal constructor( client } + /** The link of the connected client, or a [ToolError] that says what to do about its absence. */ + internal fun requireLink(): BridgeLink = + when (val state = client) { + is ClientState.Connected -> state.link + is ClientState.Launching -> + throw ToolError("session $id is still launching; call session_start with this session to wait for it") + is ClientState.Stopped -> + throw ToolError("session $id has no connected client: ${state.reason}") + } + public fun snapshot(): SessionSnapshot { val state = client val launch = diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index 32d8222d..e6e8f054 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -1,5 +1,8 @@ package net.rsprox.mcp.session +import net.rsprox.mcp.bridge.BridgeHub +import net.rsprox.mcp.bridge.BridgeLink +import net.rsprox.mcp.bridge.BridgeListener import net.rsprox.mcp.packets.PacketTap import net.rsprox.mcp.server.ToolError import net.rsprox.proxy.binary.BinaryHeader @@ -30,6 +33,7 @@ internal class Reservation( public class SessionManager internal constructor( private val launcher: ClientLauncher, private val settings: SettingSetStore, + private val bridge: BridgeHub, private val launchTimeoutMs: Long = DEFAULT_LAUNCH_TIMEOUT_MS, ) { private val sessions = CopyOnWriteArrayList() @@ -65,7 +69,10 @@ public class SessionManager internal constructor( public fun stop(session: String?): SessionSnapshot { val resolved = resolve(session) synchronized(launchLock) { - when (val state = resolved.client) { + val state = resolved.client + // Stopped first, so the close of the link below is not mistaken for the client exiting. + resolved.apply(SessionEvent.Stop("stopped by caller")) + when (state) { is ClientState.Stopped -> {} is ClientState.Launching -> launcher.kill(state.launch.proxyPort) is ClientState.Connected -> { @@ -73,7 +80,6 @@ public class SessionManager internal constructor( launcher.kill(state.launch.proxyPort) } } - resolved.apply(SessionEvent.Stop("stopped by caller")) } return resolved.snapshot() } @@ -123,6 +129,8 @@ public class SessionManager internal constructor( val launch = Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort, System.currentTimeMillis()) sessions.addIfAbsent(session) + // Registered before the client is forked: its hello can arrive before the launch call returns. + bridge.expect(launch.httpPort, listener(session, launch)) session.apply(SessionEvent.Launched(launch)) // The proxy waits for the launcher's handshake with no timeout, so the wait is bounded from outside. @@ -150,12 +158,39 @@ public class SessionManager internal constructor( throw ToolError("session ${session.id} failed to launch: $reason") } + private fun listener( + session: Session, + launch: Launch, + ): BridgeListener = + object : BridgeListener { + override val session: String = session.id.value + + override fun onHello( + link: BridgeLink, + pid: Long, + ): Boolean { + val state = session.apply(SessionEvent.Hello(launch.httpPort, link, pid)) + return state is ClientState.Connected && state.link === link + } + + override fun onClosed(link: BridgeLink) { + val before = session.client + if (session.apply(SessionEvent.LinkClosed(link)) === before) return + // The client is gone, or cannot be driven any more. Either way the proxy still holds + // its process handle and session monitor for the port. + synchronized(launchLock) { launcher.kill(launch.proxyPort) } + } + } + private fun rootMessage(throwable: Throwable): String { val root = generateSequence(throwable) { it.cause }.last() return root.message ?: root.toString() } - private companion object { + public companion object { + /** How long a start waits for the in-client bridge to connect, unless the caller says otherwise. */ + public const val DEFAULT_WAIT_MS: Long = 180_000L + // Covers a first run, where the launcher downloads the client before it handshakes. private const val DEFAULT_LAUNCH_TIMEOUT_MS = 180_000L } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt index 41c7339e..182ac338 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt @@ -1,10 +1,12 @@ package net.rsprox.mcp.server import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.bridge.TestHub import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.session.FakeLauncher import net.rsprox.mcp.session.SessionManager +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -12,9 +14,15 @@ import kotlin.test.assertTrue class ToolsTest { private val mapper = McpDispatcher.MAPPER - private val manager = SessionManager(FakeLauncher(), TapSettingSetStore) + private val fixture = TestHub() + private val manager = SessionManager(FakeLauncher(), TapSettingSetStore, fixture.hub) private val dispatcher = McpDispatcher(tools { manager }, "test") + @AfterTest + fun cleanUp() { + fixture.close() + } + private fun call( name: String, arguments: String = "{}", @@ -44,7 +52,7 @@ class ToolsTest { assertEquals( """{"session":"s1","target":"My Server","state":"launching","generation":1,"proxyPort":43751,""" + """"httpPort":43650,"cursor":1}""", - text("session_start", """{"target":"My Server"}"""), + text("session_start", """{"target":"My Server","wait_ms":0}"""), ) val list = mapper.readTree(text("session_list")) assertEquals("s1", list.get("sessions").single().get("session").asText()) @@ -57,7 +65,7 @@ class ToolsTest { @Test fun `packets read prints a meta line and one line per packet`() { - text("session_start") + text("session_start", """{"wait_ms":0}""") val log = manager.resolve("s1").packets log.append(1, 48, Origin.CLIENT, "RESUME_P_STRINGDIALOG", "[resume_p_stringdialog] string=\"McpProto\"") log.append(1, 49, Origin.SERVER, "PLAYER_INFO", "[player_info]\n [localplayer] index=1\n - move") @@ -77,7 +85,7 @@ class ToolsTest { @Test fun `packets read applies its filters and cursor`() { - text("session_start") + text("session_start", """{"wait_ms":0}""") val log = manager.resolve("s1").packets log.append(1, 1, Origin.CLIENT, "IF_BUTTON", "[if_button] com=558:7") log.append(1, 2, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"McpProto is available\"") @@ -104,7 +112,7 @@ class ToolsTest { @Test fun `an origin outside the allowed set is refused`() { - text("session_start") + text("session_start", """{"wait_ms":0}""") assertTrue(call("packets_read", """{"origin":"sideways"}""").get("isError").asBoolean()) } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt index ddf88c57..9507784e 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt @@ -1,20 +1,30 @@ package net.rsprox.mcp.session +import net.rsprox.mcp.bridge.FakePlugin +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.bridge.awaitTrue import net.rsprox.mcp.packets.PacketQuery import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.server.ToolError import net.rsprox.proxy.target.ProxyTargetConfig +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertTrue internal class FakeLauncher( private val targets: List = listOf(target(0, "Old School RuneScape"), target(1, "My Server")), ) : ClientLauncher { val reserved = ArrayList() - val killed = ArrayList() + + // A client that goes away is killed from the bridge's reader thread. + val killed = CopyOnWriteArrayList() var reserve: (ProxyTargetConfig) -> Unit = {} var launch: () -> Unit = {} @@ -33,7 +43,37 @@ internal class FakeLauncher( class SessionManagerTest { private val launcher = FakeLauncher() - private val manager = SessionManager(launcher, TapSettingSetStore, launchTimeoutMs = 200) + private val fixture = TestHub() + private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub, launchTimeoutMs = 200) + private val plugins = ArrayList() + + @AfterTest + fun cleanUp() { + plugins.forEach { it.close() } + fixture.close() + } + + private fun dial( + httpPort: Int, + pid: Long = 4242, + ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, pid = pid).also { plugins += it } + + /** A session whose client has said hello, and that client. */ + private fun connect(): FakePlugin { + manager.start(null, null, 0) + val plugin = dial(43650) + assertEquals("s1", plugin.read()?.get("session")?.asText()) + assertEquals("connected", manager.start(null, "s1", 10_000).state) + return plugin + } + + private fun markers(): List = + manager + .resolve("s1") + .packets + .read(PacketQuery()) + .packets + .map { it.prot } @Test fun `a first start launches the first custom target and stays launching`() { @@ -147,6 +187,73 @@ class SessionManagerTest { } } + @Test + fun `start returns connected, with the client pid, once the plugin says hello`() { + launcher.launch = { dial(43650, pid = 7) } + + val snapshot = manager.start(null, null, 10_000) + + assertEquals("connected", snapshot.state) + assertEquals(7, snapshot.pid) + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED"), markers()) + } + + @Test + fun `a start whose wait elapsed is extended by starting the session again`() { + assertEquals("launching", manager.start(null, null, 30).state) + val waiting = CompletableFuture.supplyAsync { manager.start(null, "s1", 10_000) } + + dial(43650) + + assertEquals("connected", waiting.get(10, TimeUnit.SECONDS).state) + assertEquals(1, launcher.reserved.size) + } + + @Test + fun `a client that goes away stops the session and releases its proxy port`() { + val plugin = connect() + + plugin.close() + + awaitTrue("the session is stopped") { manager.list().single().state == "stopped" } + assertEquals("client exited", manager.list().single().reason) + awaitTrue("the proxy port is released") { launcher.killed == listOf(43751) } + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED", "CLIENT_EXITED"), markers()) + + assertEquals(2, manager.start(null, "s1", 0).generation) + } + + @Test + fun `stopping a connected session drops its client and keeps the caller's reason`() { + val plugin = connect() + + val stopped = manager.stop(null) + + assertEquals("stopped by caller", stopped.reason) + assertNull(plugin.read()) + assertEquals("stopped by caller", manager.list().single().reason) + assertEquals(listOf(43751), launcher.killed) + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED", "CLIENT_EXITED"), markers()) + } + + @Test + fun `a client of an earlier launch does not connect the relaunched session`() { + manager.start(null, null, 0) + manager.stop(null) + manager.start(null, "s1", 0) + + val late = dial(43650) + late.read() + assertNull(late.read()) + assertEquals("launching", manager.list().single().state) + + dial(43651, pid = 9) + val relaunched = manager.start(null, "s1", 10_000) + assertEquals("connected", relaunched.state) + assertEquals(9, relaunched.pid) + assertEquals(2, relaunched.generation) + } + @Test fun `an omitted session resolves only when exactly one exists`() { val none = assertFailsWith { manager.resolve(null) } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt index 968ec08c..88152cf2 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt @@ -2,6 +2,7 @@ package net.rsprox.mcp.session import net.rsprox.mcp.bridge.idleLink import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.server.ToolError import net.rsprox.mcp.session.ClientState.Connected import net.rsprox.mcp.session.ClientState.Launching import net.rsprox.mcp.session.ClientState.Stopped @@ -14,7 +15,9 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull +import kotlin.test.assertSame internal fun target( id: Int, @@ -125,6 +128,27 @@ class SessionTest { assertEquals("My Server", connected.target) } + @Test + fun `the link is available only while a client is connected`() { + val session = Session(SessionId("s1"), target(1, "My Server")) + assertEquals( + "session s1 has no connected client: not started", + assertFailsWith { session.requireLink() }.message, + ) + session.apply(Launched(first)) + assertEquals( + "session s1 is still launching; call session_start with this session to wait for it", + assertFailsWith { session.requireLink() }.message, + ) + session.apply(Hello(first.httpPort, link, pid = 7)) + assertSame(link, session.requireLink()) + session.apply(LinkClosed(link)) + assertEquals( + "session s1 has no connected client: client exited", + assertFailsWith { session.requireLink() }.message, + ) + } + @Test fun `awaiting a launching session returns at the deadline without throwing`() { val session = Session(SessionId("s1"), target(1, "My Server")) From d451b6caa3ddb8ae87c2218c3651b7d554774bf7 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:03:12 +0200 Subject: [PATCH 08/72] feat(mcp): add the client read tools client_state, client_screenshot, client_widgets and client_vars forward to the in-client bridge. Each result carries the packet cursor taken before the call, so the packets an action caused all sit above it. A screenshot is returned as an MCP image block followed by a text block with its size. A failure the client reports becomes a tool error that starts with its code. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/server/McpHttpServer.kt | 47 +++++-- .../kotlin/net/rsprox/mcp/server/Tools.kt | 97 +++++++++++++- .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 124 +++++++++++++++++- 3 files changed, 252 insertions(+), 16 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt index 9d017660..405e2900 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -8,6 +8,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.michaelbull.logging.InlineLogger import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpServer +import net.rsprox.mcp.bridge.BridgeError import java.net.InetAddress import java.net.InetSocketAddress import java.net.URI @@ -29,6 +30,12 @@ public sealed interface ToolResult { public data class Text( val text: String, ) : ToolResult + + /** A PNG image block followed by one text block holding [meta] as compact JSON. */ + public data class Image( + val pngBase64: String, + val meta: Any, + ) : ToolResult } public class Tool( @@ -139,34 +146,48 @@ internal class McpDispatcher( val arguments = params.get("arguments")?.takeUnless { it.isNull } ?: MAPPER.createObjectNode() if (arguments !is ObjectNode) throw RpcError(INVALID_PARAMS, "params.arguments must be an object") val result = MAPPER.createObjectNode() - val text = + val content = try { checkArguments(tool, arguments) - val text = render(tool.run(arguments)) + val content = render(tool.run(arguments)) result.put("isError", false) - text + content } catch (e: ToolError) { result.put("isError", true) - e.message.orEmpty() + listOf(textBlock(e.message.orEmpty())) + } catch (e: BridgeError) { + result.put("isError", true) + listOf(textBlock("${e.code}: ${e.message}")) } catch (t: Throwable) { logger.error(t) { "Tool $name failed" } result.put("isError", true) - "internal: $t" + listOf(textBlock("internal: $t")) } - result - .putArray("content") - .addObject() - .put("type", "text") - .put("text", text) + result.putArray("content").addAll(content) return result } - private fun render(result: ToolResult): String = + private fun render(result: ToolResult): List = when (result) { - is ToolResult.Json -> MAPPER.writeValueAsString(result.value) - is ToolResult.Text -> result.text + is ToolResult.Json -> listOf(textBlock(MAPPER.writeValueAsString(result.value))) + is ToolResult.Text -> listOf(textBlock(result.text)) + is ToolResult.Image -> + listOf( + MAPPER + .createObjectNode() + .put("type", "image") + .put("data", result.pngBase64) + .put("mimeType", "image/png"), + textBlock(MAPPER.writeValueAsString(result.meta)), + ) } + private fun textBlock(text: String): ObjectNode = + MAPPER + .createObjectNode() + .put("type", "text") + .put("text", text) + /** * Checks the subset of JSON Schema the tool table uses: `required`, per-property `type`, `enum`, * `minimum`, `maximum`, and `items.type` for arrays. Unknown keys are rejected so a misspelt diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 1f3079ef..eb3867b4 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -2,6 +2,7 @@ package net.rsprox.mcp.server import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode +import net.rsprox.mcp.bridge.BridgeError import net.rsprox.mcp.packets.Cursor import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.PacketPage @@ -87,8 +88,87 @@ public fun tools(sessions: () -> SessionManager): List = val page = sessions().resolve(args.text("session")).packets.read(query, args.long("wait_ms") ?: 0) ToolResult.Text(render(page)) }, + clientTool( + name = "client_state", + description = + "Read what the client is doing: `gameState` (such as LOGIN_SCREEN or LOGGED_IN), `tick`, " + + "`canvas` as [width, height], `world`, `player` with its name and tile (null unless logged " + + "in), and `menu` with the entries a right-click would show at the current mouse position.", + schema = schema("session" to SESSION), + sessions = sessions, + ), + clientTool( + name = "client_screenshot", + description = + "Capture the game canvas as a PNG. The image has the size of the canvas, so a pixel " + + "position in it is the `x`,`y` to pass to client_click. The text block holds `width` " + + "and `height`.", + schema = schema("session" to SESSION), + sessions = sessions, + result = { ok -> ToolResult.Image(ok.remove("png")?.asText().orEmpty(), ok) }, + ), + clientTool( + name = "client_widgets", + description = + "List the interface widgets that have text, a name or actions. Each has `id` " + + "(\":\", or \":[]\" for a dynamic child), `text`, `name`, " + + "`actions`, `bounds` as [x, y, width, height], `click` as the [x, y] at its centre, `type` " + + "and `hidden`. `roots` lists the groups of the top-level interfaces. `truncated` is true " + + "when `limit` cut the list short; narrow it with `group` or `text`.", + schema = + schema( + "session" to SESSION, + "group" to integer("Keep only widgets of this interface group, such as 558.", 0), + "text" to string("Case-insensitive substring to find in the text, name or actions."), + "hidden" to boolean("Also list hidden widgets. Default false."), + "limit" to integer("Maximum number of widgets to return. Default 200.", 1, 2000), + ), + sessions = sessions, + ), + clientTool( + name = "client_vars", + description = + "Read client variables by id. The result maps each requested id to its value under " + + "`varps`, `varbits`, `varcInts` and `varcStrs`.", + schema = + schema( + "session" to SESSION, + "varps" to integerArray("Player variable ids, such as [1055]."), + "varbits" to integerArray("Varbit ids, such as [8119]."), + "varcInts" to integerArray("Client integer variable ids."), + "varcStrs" to integerArray("Client string variable ids."), + ), + sessions = sessions, + ), ) +/** + * The shape every client_* tool shares. The plugin's answer is the tool result, plus the packet cursor + * taken before the call, so everything the action caused has a sequence number above it. + */ +private fun clientTool( + name: String, + description: String, + schema: ObjectNode, + sessions: () -> SessionManager, + timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, + result: (ObjectNode) -> ToolResult = ToolResult::Json, +): Tool = + Tool(name, "$description $CURSOR_NOTE", schema) { args -> + val session = sessions().resolve(args.text("session")) + val cursor = session.packets.head().seq + val forwarded = args.deepCopy().without("session") + val ok = session.requireLink().call(name.removePrefix("client_"), forwarded, timeoutMs(args)) + if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") + result(ok.put("cursor", cursor)) + } + +private const val CLIENT_CALL_TIMEOUT_MS = 10_000L + +private const val CURSOR_NOTE = + "The result carries `cursor`, the packet cursor taken just before the call: pass it as `after` to " + + "packets_read to see only the packets from this call onwards." + private val SESSION: ObjectNode = string("Session id, such as \"s1\". May be omitted while only one session exists.") private fun render(page: PacketPage): String { @@ -119,16 +199,31 @@ private fun render(page: PacketPage): String { return out.toString() } -private fun schema(vararg properties: Pair): ObjectNode { +private fun schema( + vararg properties: Pair, + required: List = emptyList(), +): ObjectNode { val schema = McpDispatcher.MAPPER.createObjectNode() schema.put("type", "object") val node = schema.putObject("properties") for ((name, property) in properties) { node.set(name, property) } + if (required.isNotEmpty()) { + val names = schema.putArray("required") + required.forEach(names::add) + } return schema } +private fun boolean(description: String): ObjectNode = property("boolean", description) + +private fun integerArray(description: String): ObjectNode { + val node = property("array", description) + node.putObject("items").put("type", "integer") + return node +} + private fun string( description: String, vararg allowed: String, diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt index 182ac338..41f7e190 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt @@ -1,6 +1,7 @@ package net.rsprox.mcp.server import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.bridge.FakePlugin import net.rsprox.mcp.bridge.TestHub import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.TapSettingSetStore @@ -18,11 +19,23 @@ class ToolsTest { private val manager = SessionManager(FakeLauncher(), TapSettingSetStore, fixture.hub) private val dispatcher = McpDispatcher(tools { manager }, "test") + private val plugins = ArrayList() + @AfterTest fun cleanUp() { + plugins.forEach { it.close() } fixture.close() } + /** Session s1 with a connected client that answers every op through [answer]. */ + private fun connect(answer: (op: String, args: JsonNode) -> String) { + manager.start(null, null, 0) + val plugin = FakePlugin.dial(fixture.rendezvous, 43650).also { plugins += it } + plugin.read() + assertEquals("connected", manager.start(null, "s1", 10_000).state) + plugin.serve(answer) + } + private fun call( name: String, arguments: String = "{}", @@ -41,10 +54,22 @@ class ToolsTest { } @Test - fun `the four tools are listed`() { + fun `the tools are listed`() { val body = dispatcher.handle("POST", null, """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""").body val names = mapper.readTree(body).get("result").get("tools").map { it.get("name").asText() } - assertEquals(listOf("session_start", "session_stop", "session_list", "packets_read"), names) + assertEquals( + listOf( + "session_start", + "session_stop", + "session_list", + "packets_read", + "client_state", + "client_screenshot", + "client_widgets", + "client_vars", + ), + names, + ) } @Test @@ -115,4 +140,99 @@ class ToolsTest { text("session_start", """{"wait_ms":0}""") assertTrue(call("packets_read", """{"origin":"sideways"}""").get("isError").asBoolean()) } + + @Test + fun `a client tool returns the plugin's answer with the cursor taken before the call`() { + val forwarded = ArrayList>() + connect { op, args -> + forwarded += op to args + // What the action causes arrives while the call is in flight. + manager.resolve("s1").packets.append(1, 5, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"hello\"") + """"ok":{"gameState":"LOGIN_SCREEN","tick":0}""" + } + val log = manager.resolve("s1").packets + val before = log.head().seq + + val result = text("client_state", """{"session":"s1"}""") + + assertEquals("""{"gameState":"LOGIN_SCREEN","tick":0,"cursor":$before}""", result) + assertEquals(before + 1, log.head().seq) + assertEquals(listOf("state" to mapper.readTree("{}")), forwarded) + } + + @Test + fun `a client tool forwards its arguments without the session`() { + val forwarded = ArrayList>() + connect { op, args -> + forwarded += op to args + """"ok":{"roots":[548],"widgets":[],"truncated":false}""" + } + + text("client_widgets", """{"session":"s1","group":558,"text":"name","hidden":true,"limit":5}""") + text("client_vars", """{"varps":[1055],"varbits":[8119]}""") + + assertEquals( + listOf( + "widgets" to mapper.readTree("""{"group":558,"text":"name","hidden":true,"limit":5}"""), + "vars" to mapper.readTree("""{"varps":[1055],"varbits":[8119]}"""), + ), + forwarded, + ) + } + + @Test + fun `a screenshot is an image block followed by its size and the cursor`() { + connect { _, _ -> """"ok":{"png":"iVBORw0KGgo=","width":976,"height":558}""" } + val cursor = manager.resolve("s1").packets.head().seq + + val content = call("client_screenshot").get("content") + + assertEquals( + mapper.readTree( + """[{"type":"image","data":"iVBORw0KGgo=","mimeType":"image/png"}, + {"type":"text","text":"{\"width\":976,\"height\":558,\"cursor\":$cursor}"}]""", + ), + content, + ) + } + + @Test + fun `a failure reported by the client becomes a tool error with its code`() { + connect { _, _ -> """"err":{"code":"not_found","message":"varbit 99999 could not be read"}""" } + + val result = call("client_vars", """{"varbits":[99999]}""") + + assertTrue(result.get("isError").asBoolean()) + assertEquals("not_found: varbit 99999 could not be read", result.get("content")[0].get("text").asText()) + } + + @Test + fun `a client tool on a session without a connected client says how to get one`() { + text("session_start", """{"wait_ms":0}""") + val launching = call("client_state") + assertTrue(launching.get("isError").asBoolean()) + assertEquals( + "session s1 is still launching; call session_start with this session to wait for it", + launching.get("content")[0].get("text").asText(), + ) + + text("session_stop") + assertEquals( + "session s1 has no connected client: stopped by caller", + call("client_state").get("content")[0].get("text").asText(), + ) + } + + @Test + fun `a client tool refuses arguments of the wrong type before calling the client`() { + val calls = ArrayList() + connect { op, _ -> + calls += op + """"ok":{}""" + } + + assertTrue(call("client_vars", """{"varps":["1055"]}""").get("isError").asBoolean()) + assertTrue(call("client_widgets", """{"hidden":"yes"}""").get("isError").asBoolean()) + assertEquals(emptyList(), calls) + } } From e60f30439ff83bcd12fd9fe1b2dfa5bfbc7a0e43 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:03:24 +0200 Subject: [PATCH 09/72] feat(mcp): add the client input tools client_login, client_click and client_type drive the client through the in-client bridge. A click takes canvas coordinates or a widget id. The login password is optional and defaults to empty. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/server/Tools.kt | 51 ++++++++++++++++ .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 60 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index eb3867b4..e88f59ae 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -140,6 +140,54 @@ public fun tools(sessions: () -> SessionManager): List = ), sessions = sessions, ), + clientTool( + name = "client_login", + description = + "Log in from the login screen with the given credentials and wait until the client is in " + + "the game. Returns `gameState` LOGGED_IN. Fails with `wrong_state` when the client is " + + "not on the login screen or the login is refused, and with `timeout` when `wait_ms` " + + "elapses first.", + schema = + schema( + "session" to SESSION, + "username" to string("Account name to log in with."), + "password" to string("Password. Default: empty, for servers that accept any."), + "wait_ms" to integer("How long to wait for the login to complete. Default 15000.", 0, 120_000), + required = listOf("username"), + ), + sessions = sessions, + timeoutMs = { args -> (args.long("wait_ms") ?: LOGIN_WAIT_MS) + CLIENT_CALL_TIMEOUT_MS }, + ), + clientTool( + name = "client_click", + description = + "Click on the game canvas, either at `x`,`y` in canvas units (the pixels of a " + + "client_screenshot) or at the centre of `widget`. Returns the `x`,`y` that was clicked. " + + "Fails with `not_found` when the widget does not exist or is not visible.", + schema = + schema( + "session" to SESSION, + "x" to integer("Horizontal canvas position. Requires `y`.", 0), + "y" to integer("Vertical canvas position. Requires `x`.", 0), + "widget" to string("Widget id as listed by client_widgets, such as \"558:7\" or \"558:7[3]\"."), + "button" to string("Mouse button. Default left.", "left", "right"), + ), + sessions = sessions, + ), + clientTool( + name = "client_type", + description = + "Type text into the client as key presses, as if on the keyboard. Returns `typed`, the " + + "number of characters sent.", + schema = + schema( + "session" to SESSION, + "text" to string("The characters to type."), + "enter" to boolean("Press Enter after the text. Default false."), + required = listOf("text"), + ), + sessions = sessions, + ), ) /** @@ -165,6 +213,9 @@ private fun clientTool( private const val CLIENT_CALL_TIMEOUT_MS = 10_000L +// Mirrors the default the plugin applies when `wait_ms` is absent. +private const val LOGIN_WAIT_MS = 15_000L + private const val CURSOR_NOTE = "The result carries `cursor`, the packet cursor taken just before the call: pass it as `after` to " + "packets_read to see only the packets from this call onwards." diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt index 41f7e190..73e3f7e9 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt @@ -67,6 +67,9 @@ class ToolsTest { "client_screenshot", "client_widgets", "client_vars", + "client_login", + "client_click", + "client_type", ), names, ) @@ -235,4 +238,61 @@ class ToolsTest { assertTrue(call("client_widgets", """{"hidden":"yes"}""").get("isError").asBoolean()) assertEquals(emptyList(), calls) } + + @Test + fun `the input tools forward their arguments and return the client's answer`() { + val forwarded = ArrayList>() + connect { op, args -> + forwarded += op to args + when (op) { + "login" -> """"ok":{"gameState":"LOGGED_IN"}""" + "click" -> """"ok":{"x":380,"y":215}""" + else -> """"ok":{"typed":8}""" + } + } + val cursor = manager.resolve("s1").packets.head().seq + + assertEquals( + """{"gameState":"LOGGED_IN","cursor":$cursor}""", + text("client_login", """{"username":"mcp","wait_ms":2000}"""), + ) + assertEquals("""{"x":380,"y":215,"cursor":$cursor}""", text("client_click", """{"widget":"558:7"}""")) + text("client_click", """{"session":"s1","x":380,"y":215,"button":"right"}""") + assertEquals("""{"typed":8,"cursor":$cursor}""", text("client_type", """{"text":"McpProto","enter":true}""")) + + assertEquals( + listOf( + "login" to mapper.readTree("""{"username":"mcp","wait_ms":2000}"""), + "click" to mapper.readTree("""{"widget":"558:7"}"""), + "click" to mapper.readTree("""{"x":380,"y":215,"button":"right"}"""), + "type" to mapper.readTree("""{"text":"McpProto","enter":true}"""), + ), + forwarded, + ) + } + + @Test + fun `a login needs a username and accepts a missing password`() { + val calls = ArrayList() + connect { _, args -> + calls.add(args) + """"ok":{"gameState":"LOGGED_IN"}""" + } + + val missing = call("client_login", """{"password":"secret"}""") + assertTrue(missing.get("isError").asBoolean()) + assertEquals( + "client_login: missing required argument 'username'", + missing.get("content")[0].get("text").asText(), + ) + + text("client_login", """{"username":"mcp"}""") + assertEquals(listOf(mapper.readTree("""{"username":"mcp"}""")), calls) + } + + @Test + fun `a button other than left or right is refused`() { + connect { _, _ -> """"ok":{}""" } + assertTrue(call("client_click", """{"x":1,"y":1,"button":"middle"}""").get("isError").asBoolean()) + } } From 74897ad8de8ddd0217cbdd3b5a78a146977b21b0 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:04:13 +0200 Subject: [PATCH 10/72] docs(mcp): describe how to run and use the MCP server Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 mcp/README.md diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 00000000..43f28655 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,141 @@ +# rsprox MCP server + +This module runs rsprox without its GUI and serves it to a coding agent as an +[MCP](https://modelcontextprotocol.io) server. The agent launches a RuneLite client through the proxy, +reads the decoded packets in both directions, and looks at and drives the client. It is meant for +checking a private server end to end with a real client. + +The server has two halves. The proxy half runs in this process and records every packet. The client +half is a small RuneLite plugin that the server installs into the client's sideload directory before +each launch. The plugin connects back to the server over loopback. + +## Run it + +``` +./gradlew :mcp:run +``` + +The server listens on `http://127.0.0.1:43580/mcp`. It uses the proxy targets that the rsprox GUI +uses, from `~/.rsprox/proxy-targets.yaml`. The GUI may run at the same time. + +Options go in `--args`: + +``` +./gradlew :mcp:run --args="--start 'My Server'" +``` + +| Option | Meaning | +|---|---| +| `--port ` | Loopback port of the MCP endpoint. Default 43580. | +| `--start ` | Launch a client for this target at startup. | +| `--sideload-dir ` | Where to install the client plugin, when the client does not use the default directory. | +| `--port-skip ` | Proxy ports to leave free at the start of the range for a GUI. Default 50. | + +The plugin is installed as `rsprox-mcp-bridge.jar` in `~/.rlcustom/sideloaded-plugins` for a custom +target and in `~/.runelite/sideloaded-plugins` for the official one. A client that the MCP server did +not launch loads the plugin too, and the plugin then does nothing. + +## Register it with an MCP client + +The transport is plain HTTP. With Claude Code: + +``` +claude mcp add --transport http rsprox http://127.0.0.1:43580/mcp +``` + +Any other client takes the same URL in its configuration for an HTTP server: + +```json +{ "mcpServers": { "rsprox": { "type": "http", "url": "http://127.0.0.1:43580/mcp" } } } +``` + +Start the server before the client connects. The server keeps its sessions and packets for as long as +it runs, whatever the clients do. + +## Tools + +`session` is optional on every tool while only one session exists. + +| Tool | What it does | +|---|---| +| `session_start` | Launches a client for a target, or relaunches a stopped session. Waits until the client plugin has connected. | +| `session_stop` | Kills the client of a session. The session and its packets stay readable. | +| `session_list` | Lists the sessions with their state, ports, login and packet cursor, and the target names. | +| `packets_read` | Reads decoded packets after a cursor, unfiltered, in both directions. With `wait_ms` it waits for the first match. | +| `client_state` | Reads the game state, tick, canvas size, world, local player and right-click menu. | +| `client_screenshot` | Returns the game canvas as a PNG. Image pixels are click coordinates. | +| `client_widgets` | Lists the interface widgets that have text, a name or actions, each with its bounds and click point. | +| `client_vars` | Reads varps, varbits and client variables by id. | +| `client_login` | Logs in from the login screen and waits until the client is in the game. | +| `client_click` | Clicks at canvas coordinates or at the centre of a widget. | +| `client_type` | Types text as key presses, with an optional Enter. | + +Every `client_*` result carries `cursor`, the packet cursor taken just before the call. Pass it as +`after` to `packets_read` to see only what the action caused. + +Each packet is one line: ` L T `. `C` is client to server, `S` +is server to client, and `P` is a marker that rsprox adds (`CLIENT_LAUNCHED`, `CLIENT_CONNECTED`, +`CLIENT_EXITED`, `LOGIN`, `LOGOUT`). + +## Example: check that setting a display name works + +The widget ids and packet contents below are illustrative. They depend on the server. + +``` +1. session_start {"target":"My Server"} + -> {"session":"s1","target":"My Server","state":"connected","generation":1,"proxyPort":43751, + "httpPort":43650,"pid":40388,"cursor":2} + +2. client_login {"username":"mcptest"} + -> {"gameState":"LOGGED_IN","cursor":2} + +3. client_widgets {"text":"look up name"} + -> {"roots":[548],"widgets":[{"id":"558:7","text":"Look up name","name":"","actions":["Look up name"], + "bounds":[315,200,130,30],"click":[380,215],"type":4,"hidden":false}],"truncated":false,"cursor":604} + +4. client_click {"widget":"558:7"} + -> {"x":380,"y":215,"cursor":640} + +5. packets_read {"after":640,"prots":["IF_BUTTONX"],"wait_ms":3000} + -> {"next":647,"head":647,"dropped":0,"timedOut":false,"count":1} + 645 L1 T46 C IF_BUTTONX [if_buttonx] com=558:7, sub=-1, obj=-1, button=1 + +6. client_type {"text":"McpProto","enter":true} + -> {"typed":8,"cursor":655} + +7. packets_read {"after":655,"prots":["RESUME_P_STRINGDIALOG","IF_SETTEXT"],"contains":"McpProto","wait_ms":5000} + -> {"next":671,"head":671,"dropped":0,"timedOut":false,"count":2} + 662 L1 T48 C RESUME_P_STRINGDIALOG [resume_p_stringdialog] string="McpProto" + 669 L1 T49 S IF_SETTEXT [if_settext] com=558:13, text="McpProto is available" + +8. client_widgets {"group":558,"text":"is available"} + -> {"roots":[548],"widgets":[{"id":"558:13","text":"McpProto is available", ...}], + "truncated":false,"cursor":671} + +9. session_stop {} + -> {"session":"s1","target":"My Server","state":"stopped","reason":"stopped by caller", ...} +``` + +Steps 5, 7 and 8 are three separate proofs. The client sent the click. The client sent the name and +the server answered. The client rendered the answer. + +Two more patterns are useful: + +- To assert that nothing happened, wait for the packet and expect a timeout. + `packets_read {"after":655,"prots":["IF_SETTEXT"],"wait_ms":1800}` returning `"timedOut":true` means + the server sent no such packet for three ticks. +- After a server restart, call `session_start {"session":"s1"}`. The session keeps its id and its + packet log. The client comes back on new ports, and new packets are marked with the next login + number. + +## Known limits + +- The client needs a display. The server has no GUI of its own, but RuneLite opens a window. +- The proxy only observes. It cannot inject or change packets. All input goes through the client as + mouse and key events. +- A launch whose launcher never completes its handshake is abandoned after 180 seconds. After that, + the server refuses further launches until it is restarted. +- Sessions record to the usual folder of the target, as they do when launched from the GUI. +- The rendezvous file `~/.rsprox/mcp/bridge.json` is shared, so run one MCP server per user account. +- Key codes of typed text are zeroed in the recorded packets, as in every rsprox recording. Read what + was typed from the packet that carries the text, such as `RESUME_P_STRINGDIALOG`. From 8e613ef6ed83be684cff7dba05807df9ce4fd252 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:29:32 +0200 Subject: [PATCH 11/72] fix(mcp): require a login password and wait for the login screen A login with an empty password is refused, which the tool reported as a refused login with no hint at the cause. The password is now a required argument. The bridge connects while the client is still loading, so a login sent right after session_start could arrive before the login screen exists. The login now waits for the login screen until wait_ms elapses, and says so when the client never gets there, as happens when the game server is unreachable. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 2 +- .../kotlin/net/rsprox/mcp/server/Tools.kt | 11 ++++---- .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 17 +++++++---- .../main/java/net/rsprox/mcpbridge/Ops.java | 28 ++++++++++++++++++- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 43f28655..175466f6 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -86,7 +86,7 @@ The widget ids and packet contents below are illustrative. They depend on the se -> {"session":"s1","target":"My Server","state":"connected","generation":1,"proxyPort":43751, "httpPort":43650,"pid":40388,"cursor":2} -2. client_login {"username":"mcptest"} +2. client_login {"username":"mcptest","password":"any"} -> {"gameState":"LOGGED_IN","cursor":2} 3. client_widgets {"text":"look up name"} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index e88f59ae..2fdd9821 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -143,17 +143,18 @@ public fun tools(sessions: () -> SessionManager): List = clientTool( name = "client_login", description = - "Log in from the login screen with the given credentials and wait until the client is in " + - "the game. Returns `gameState` LOGGED_IN. Fails with `wrong_state` when the client is " + - "not on the login screen or the login is refused, and with `timeout` when `wait_ms` " + + "Log in with the given credentials and wait until the client is in the game. A client that is " + + "still starting is given until `wait_ms` to reach the login screen first. " + + "Returns `gameState` LOGGED_IN. Fails with `wrong_state` when the client is already past " + + "the login screen or the login is refused, and with `timeout` when `wait_ms` " + "elapses first.", schema = schema( "session" to SESSION, "username" to string("Account name to log in with."), - "password" to string("Password. Default: empty, for servers that accept any."), + "password" to string("Password. An empty one is refused, so pass any value to a server that ignores it."), "wait_ms" to integer("How long to wait for the login to complete. Default 15000.", 0, 120_000), - required = listOf("username"), + required = listOf("username", "password"), ), sessions = sessions, timeoutMs = { args -> (args.long("wait_ms") ?: LOGIN_WAIT_MS) + CLIENT_CALL_TIMEOUT_MS }, diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt index 73e3f7e9..c6adffb3 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt @@ -254,7 +254,7 @@ class ToolsTest { assertEquals( """{"gameState":"LOGGED_IN","cursor":$cursor}""", - text("client_login", """{"username":"mcp","wait_ms":2000}"""), + text("client_login", """{"username":"mcp","password":"secret","wait_ms":2000}"""), ) assertEquals("""{"x":380,"y":215,"cursor":$cursor}""", text("client_click", """{"widget":"558:7"}""")) text("client_click", """{"session":"s1","x":380,"y":215,"button":"right"}""") @@ -262,7 +262,7 @@ class ToolsTest { assertEquals( listOf( - "login" to mapper.readTree("""{"username":"mcp","wait_ms":2000}"""), + "login" to mapper.readTree("""{"username":"mcp","password":"secret","wait_ms":2000}"""), "click" to mapper.readTree("""{"widget":"558:7"}"""), "click" to mapper.readTree("""{"x":380,"y":215,"button":"right"}"""), "type" to mapper.readTree("""{"text":"McpProto","enter":true}"""), @@ -272,7 +272,7 @@ class ToolsTest { } @Test - fun `a login needs a username and accepts a missing password`() { + fun `a login needs a username and a password`() { val calls = ArrayList() connect { _, args -> calls.add(args) @@ -286,8 +286,15 @@ class ToolsTest { missing.get("content")[0].get("text").asText(), ) - text("client_login", """{"username":"mcp"}""") - assertEquals(listOf(mapper.readTree("""{"username":"mcp"}""")), calls) + val noPassword = call("client_login", """{"username":"mcp"}""") + assertTrue(noPassword.get("isError").asBoolean()) + assertEquals( + "client_login: missing required argument 'password'", + noPassword.get("content")[0].get("text").asText(), + ) + + text("client_login", """{"username":"mcp","password":"secret"}""") + assertEquals(listOf(mapper.readTree("""{"username":"mcp","password":"secret"}""")), calls) } @Test diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 69a580c9..c8f9045f 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -366,15 +366,19 @@ private JsonElement login(JsonObject args) throws BridgeException { throw new BridgeException("bad_args", "username is required"); } String password = optionalString(args, "password"); + if (password == null || password.isEmpty()) { + throw new BridgeException("bad_args", "password is required and must not be empty"); + } Integer wait = optionalInt(args, "wait_ms"); long deadline = System.currentTimeMillis() + (wait == null ? DEFAULT_LOGIN_WAIT_MS : wait); + awaitLoginScreen(deadline); game.read(client -> { GameState state = client.getGameState(); if (state != GameState.LOGIN_SCREEN) { throw new BridgeException("wrong_state", "the client is not on the login screen: " + state); } client.setUsername(username); - client.setPassword(password == null ? "" : password); + client.setPassword(password); client.setGameState(GameState.LOGGING_IN); return null; }); @@ -400,6 +404,28 @@ private JsonElement login(JsonObject args) throws BridgeException { } } + /** The bridge connects while the client is still loading, and a login set before the login screen is lost. */ + private void awaitLoginScreen(long deadline) throws BridgeException { + while (true) { + GameState state = game.read(Client::getGameState); + if (state != GameState.STARTING && state != GameState.UNKNOWN) { + return; + } + if (System.currentTimeMillis() >= deadline) { + throw new BridgeException( + "timeout", + "the client did not reach the login screen; gameState is " + state + + ". A client that cannot reach the game server stays in this state."); + } + try { + Thread.sleep(LOGIN_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while waiting for the login screen"); + } + } + } + private static Point centre(Rectangle bounds) { return new Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); } From 315370b15d8b3e982c192a7abaa8f8a819021676 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:39:43 +0200 Subject: [PATCH 12/72] feat(mcp): run without a physical display On a virtual display such as Xvfb the client works, but the GPU plugin renders through a software OpenGL driver and the frames read back from it are black, so client_screenshot returned an empty image. --software-rendering makes the bridge plugin stop the GPU plugin in each client the server launches. It waits until the GPU plugin renders before stopping it, because that plugin finishes starting on the client thread after it is marked active, and an earlier stop is undone by that start. The plugin is stopped, not disabled, so the RuneLite profile is left as it was for clients started any other way. The README describes the Xvfb setup and the packages it needs. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 31 +++++++++++- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 8 ++- .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 9 +++- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 11 +++++ .../net/rsprox/mcp/bridge/FakePlugin.kt | 6 ++- .../rsprox/mcpbridge/BridgeConnection.java | 14 +++++- .../net/rsprox/mcpbridge/McpBridgePlugin.java | 49 +++++++++++++++++++ 7 files changed, 121 insertions(+), 7 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 175466f6..01f052ca 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -30,11 +30,40 @@ Options go in `--args`: | `--start ` | Launch a client for this target at startup. | | `--sideload-dir ` | Where to install the client plugin, when the client does not use the default directory. | | `--port-skip ` | Proxy ports to leave free at the start of the range for a GUI. Default 50. | +| `--software-rendering` | Stop the GPU plugin in launched clients. Needed on a virtual display. | The plugin is installed as `rsprox-mcp-bridge.jar` in `~/.rlcustom/sideloaded-plugins` for a custom target and in `~/.runelite/sideloaded-plugins` for the official one. A client that the MCP server did not launch loads the plugin too, and the plugin then does nothing. +## Run it without a display + +RuneLite always opens a window, so on a machine with no screen give it a virtual one. On Linux, run +the server under Xvfb and pass `--software-rendering`: + +``` +xvfb-run -s "-screen 0 1280x800x24" ./gradlew :mcp:run --args="--software-rendering" +``` + +Without `--software-rendering` everything works except `client_screenshot`, which returns a black +image. The GPU plugin renders with a software OpenGL driver on a virtual display, and the frames read +back from it are empty. The option stops the GPU plugin in each client the server launches. It does +not change the RuneLite profile, so clients started any other way keep their GPU setting. + +Besides a JDK, the machine needs Xvfb and the X11 and font libraries that Java's AWT loads. On Debian +or Ubuntu: + +``` +apt-get install xvfb libxrender1 libxtst6 libxi6 libxext6 libfontconfig1 libfreetype6 fonts-dejavu-core +``` + +The client reports that audio is unavailable when the machine has no sound device. That is harmless. + +A custom target reaches each world through a loopback address such as `127.0.1.3`. Linux routes all +of `127.0.0.0/8` to loopback, so it needs no alias. + +There is no virtual display on macOS or Windows, so there the client needs a real screen. + ## Register it with an MCP client The transport is plain HTTP. With Claude Code: @@ -130,7 +159,7 @@ Two more patterns are useful: ## Known limits -- The client needs a display. The server has no GUI of its own, but RuneLite opens a window. +- The client needs a display, real or virtual. See "Run it without a display". - The proxy only observes. It cannot inject or change packets. All input goes through the client as mouse and key events. - A launch whose launcher never completes its handshake is abandoned after 180 seconds. After that, diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 87cb5ac1..ca9f0fb4 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -2,6 +2,7 @@ package net.rsprox.mcp import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.path @@ -32,6 +33,10 @@ public class McpCommand : CliktCommand(name = "mcp") { "--sideload-dir", help = "Directory the client sideloads plugins from, when it is not the default of the target", ).path() + private val softwareRendering by option( + "--software-rendering", + help = "Stop the GPU plugin in launched clients, for a virtual display such as Xvfb", + ).flag() private val autostart by option("--start", help = "Target name to launch immediately") override fun run() { @@ -54,7 +59,8 @@ public class McpCommand : CliktCommand(name = "mcp") { service.filterSetStore = UnfilteredFilterSetStore service.settingsStore = TapSettingSetStore val launcher = ProxyServiceLauncher(service, portSkip.coerceAtLeast(1), BridgeJar(sideloadDir)) - val hub = BridgeHub(Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json")) + val hub = + BridgeHub(Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"), softwareRendering) hub.start() // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index a59058c5..5d1e99a6 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -39,6 +39,7 @@ internal interface BridgeListener { */ public class BridgeHub( private val rendezvous: Path, + private val softwareRendering: Boolean = false, ) : AutoCloseable { private val expected = ConcurrentHashMap() private val links = ConcurrentHashMap.newKeySet() @@ -133,7 +134,13 @@ public class BridgeHub( listener.onClosed(closed) } // The welcome goes out before the session can see the link, so no request can overtake it. - writer.line(MAPPER.createObjectNode().put("welcome", PROTOCOL).put("session", listener.session)) + writer.line( + MAPPER + .createObjectNode() + .put("welcome", PROTOCOL) + .put("session", listener.session) + .put("softwareRendering", softwareRendering), + ) if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) { socket.close() return diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index 2477d5ec..c977c130 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -84,9 +84,20 @@ class BridgeHubTest { val welcome = plugin.read()!! assertEquals(1, welcome.get("welcome").asInt()) assertEquals("s3", welcome.get("session").asText()) + assertFalse(welcome.get("softwareRendering").asBoolean()) assertEquals(4242, listener.hello.get(10, TimeUnit.SECONDS).second) } + @Test + fun `a hub set to software rendering tells each client it welcomes`() { + TestHub(softwareRendering = true).use { other -> + other.hub.expect(43650, RecordingListener()) + val plugin = FakePlugin.dial(other.rendezvous, 43650, null, 1).also { plugins += it } + + assertTrue(plugin.read()!!.get("softwareRendering").asBoolean()) + } + } + @Test fun `a wrong token is rejected and leaves the expectation in place`() { val listener = RecordingListener() diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index edbdddb7..3c2da687 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -32,9 +32,11 @@ internal fun awaitTrue( } /** A started hub whose rendezvous file lives in a temporary directory. */ -internal class TestHub : AutoCloseable { +internal class TestHub( + softwareRendering: Boolean = false, +) : AutoCloseable { val rendezvous: Path = Files.createTempDirectory("mcp-bridge-test").resolve("mcp").resolve("bridge.json") - val hub = BridgeHub(rendezvous).also { it.start() } + val hub = BridgeHub(rendezvous, softwareRendering).also { it.start() } override fun close() { hub.close() diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java index efc5a1ea..f1902fe4 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -39,10 +39,13 @@ final class BridgeConnection implements AutoCloseable { private final BufferedWriter writer; private final Gson gson; private final Ops ops; + private final boolean softwareRendering; private final AtomicBoolean closed = new AtomicBoolean(); private ExecutorService workers; - private BridgeConnection(Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops) { + private BridgeConnection( + Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops, boolean softwareRendering) { + this.softwareRendering = softwareRendering; this.socket = socket; this.reader = reader; this.writer = writer; @@ -93,7 +96,9 @@ static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { return null; } socket.setSoTimeout(0); - BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops); + boolean softwareRendering = + answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); + BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, softwareRendering); connection.start(); log.info("rsprox MCP bridge connected as session {}", answer.get("session")); return connection; @@ -108,6 +113,11 @@ static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { } } + /** Whether rsprox asked for a client that renders without the GPU plugin. */ + boolean softwareRendering() { + return softwareRendering; + } + /** The port in the client's jav_config argument, which is how rsprox tells its clients apart. */ private static int httpPortFromCommandLine() { String command = System.getProperty("sun.java.command", ""); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index 0bb44103..0108dd0d 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -8,7 +8,11 @@ import net.runelite.client.callback.ClientThread; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.PluginInstantiationException; +import net.runelite.client.plugins.PluginManager; import net.runelite.client.ui.DrawManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * The in-client half of the rsprox MCP server. The sideload directory is shared with clients that @@ -21,10 +25,16 @@ hidden = true ) public class McpBridgePlugin extends Plugin { + private static final Logger log = LoggerFactory.getLogger(McpBridgePlugin.class); + private static final String GPU_PLUGIN = "GpuPlugin"; + private static final long GPU_START_WAIT_MS = 60_000; + private static final long GPU_POLL_MS = 100; + @Inject private Client client; @Inject private ClientThread clientThread; @Inject private DrawManager drawManager; @Inject private Gson gson; + @Inject private PluginManager pluginManager; private BridgeConnection connection; @@ -34,6 +44,45 @@ protected void startUp() { Ops ops = new Ops(new GameAccess(client, clientThread, drawManager)); // The protocol spells out an absent player as null, which the client's Gson would drop. connection = BridgeConnection.dial(rendezvous, gson.newBuilder().serializeNulls().create(), ops); + if (connection != null && connection.softwareRendering()) { + Thread thread = new Thread(this::stopGpuPlugin, "mcp-bridge-gpu-off"); + thread.setDaemon(true); + thread.start(); + } + } + + /** + * Frames read back from the GPU plugin are black on a virtual display. Stopping the plugin, instead + * of disabling it, leaves the profile untouched for clients that share it. + */ + private void stopGpuPlugin() { + Plugin gpu = null; + for (Plugin plugin : pluginManager.getPlugins()) { + if (plugin.getClass().getSimpleName().equals(GPU_PLUGIN)) { + gpu = plugin; + } + } + if (gpu == null || !pluginManager.isPluginEnabled(gpu)) { + return; + } + // The GPU plugin finishes starting on the client thread, some time after it is marked active. + // Stopping it before then lets that late start win, so wait until the client renders through it. + long deadline = System.currentTimeMillis() + GPU_START_WAIT_MS; + try { + while (!(pluginManager.isPluginActive(gpu) && client.isGpu())) { + if (System.currentTimeMillis() >= deadline) { + log.info("The GPU plugin did not take over rendering, so there is nothing to stop"); + return; + } + Thread.sleep(GPU_POLL_MS); + } + pluginManager.stopPlugin(gpu); + log.info("Stopped the GPU plugin for software rendering"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (PluginInstantiationException e) { + log.warn("Unable to stop the GPU plugin", e); + } } @Override From cdae8329e41997bd2fd0e7f750c2e3aa6296b730 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 16:49:01 +0200 Subject: [PATCH 13/72] style(mcp): space statements into groups and mark empty bodies Review feedback on the bridge and the packet log asked for blank lines between steps. The same rules are applied to every file of the module and the plugin: a blank line after a block and after a statement that spans lines, and before a control statement, a comment and a return. A single declaration stays attached to the check that reads it. Empty function bodies now hold a lone comment marker, and tokenMatches is a block with a guard clause, where a three-line boolean expression was easy to misread. Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 5 ++ .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 25 +++++- .../kotlin/net/rsprox/mcp/bridge/BridgeJar.kt | 4 + .../net/rsprox/mcp/bridge/BridgeLink.kt | 6 ++ .../net/rsprox/mcp/packets/PacketLog.kt | 8 ++ .../net/rsprox/mcp/packets/PacketTap.kt | 46 ++++++++--- .../net/rsprox/mcp/packets/Unfiltered.kt | 80 ++++++++++++++----- .../net/rsprox/mcp/server/McpHttpServer.kt | 31 +++++++ .../kotlin/net/rsprox/mcp/server/Tools.kt | 14 ++++ .../mcp/session/ProxyServiceLauncher.kt | 5 ++ .../kotlin/net/rsprox/mcp/session/Session.kt | 5 ++ .../net/rsprox/mcp/session/SessionManager.kt | 23 +++++- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 3 + .../net/rsprox/mcp/bridge/FakePlugin.kt | 4 + .../net/rsprox/mcp/packets/PacketLogTest.kt | 1 + .../rsprox/mcp/server/McpDispatcherTest.kt | 4 + .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 6 ++ .../rsprox/mcp/session/SessionManagerTest.kt | 5 ++ .../rsprox/mcpbridge/BridgeConnection.java | 22 +++++ .../java/net/rsprox/mcpbridge/GameAccess.java | 5 ++ .../net/rsprox/mcpbridge/McpBridgePlugin.java | 8 ++ .../main/java/net/rsprox/mcpbridge/Ops.java | 76 ++++++++++++++++++ 22 files changed, 350 insertions(+), 36 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index ca9f0fb4..dff33cf2 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -48,6 +48,7 @@ public class McpCommand : CliktCommand(name = "mcp") { tools { sessions.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, System.getenv("APP_VERSION") ?: "dev", ) + // Binding first makes a second instance exit here, before it touches the shared configuration. http.start() logger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } @@ -56,12 +57,15 @@ public class McpCommand : CliktCommand(name = "mcp") { service.start(null, null) { percentage, _, subActionText, _ -> logger.debug { "Starting proxy service: $subActionText (${(percentage * 100).toInt()}%)" } } + service.filterSetStore = UnfilteredFilterSetStore service.settingsStore = TapSettingSetStore val launcher = ProxyServiceLauncher(service, portSkip.coerceAtLeast(1), BridgeJar(sideloadDir)) val hub = BridgeHub(Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"), softwareRendering) + hub.start() + // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) val manager = SessionManager(launcher, service.settingsStore, hub) @@ -75,6 +79,7 @@ public class McpCommand : CliktCommand(name = "mcp") { logger.error { "Unable to start '$target': ${e.message}" } } } + Thread.currentThread().join() } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index 5d1e99a6..80e13381 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -52,7 +52,9 @@ public class BridgeHub( public fun start() { val server = ServerSocket(0, BACKLOG, InetAddress.getLoopbackAddress()) this.server = server + writeRendezvous(server.localPort) + val thread = Thread({ acceptLoop(server) }, "mcp-bridge-accept") thread.isDaemon = true thread.start() @@ -80,13 +82,16 @@ public class BridgeHub( .put("port", port) .put("token", token) .put("pid", ProcessHandle.current().pid()) + Files.createDirectories(rendezvous.parent) + // Written beside the target and moved into place, so a plugin never reads a partial file // and the token is never in a file that others may read. val temp = Files.createTempFile(rendezvous.parent, "bridge", ".tmp") if (Files.getFileStore(temp).supportsFileAttributeView("posix")) { Files.setPosixFilePermissions(temp, PosixFilePermissions.fromString("rw-------")) } + Files.write(temp, MAPPER.writeValueAsBytes(content)) Files.move(temp, rendezvous, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } @@ -99,6 +104,7 @@ public class BridgeHub( } catch (e: IOException) { return } + try { greet(socket) } catch (e: Exception) { @@ -121,18 +127,22 @@ public class BridgeHub( "bridge protocol ${hello.get("hello")} is not supported; the installed plugin jar is stale" else -> null } + val listener = if (refusal == null) expected.remove(httpPort) else null if (listener == null) { writer.line(MAPPER.createObjectNode().put("reject", refusal ?: "no session expects httpPort $httpPort")) socket.close() + return } + socket.soTimeout = 0 val link = BridgeLink(socket, reader, writer) { closed -> links.remove(closed) listener.onClosed(closed) } + // The welcome goes out before the session can see the link, so no request can overtake it. writer.line( MAPPER @@ -141,19 +151,25 @@ public class BridgeHub( .put("session", listener.session) .put("softwareRendering", softwareRendering), ) + if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) { socket.close() + return } + links += link link.start() logger.info { "Bridge connected for session ${listener.session} (httpPort $httpPort)" } } - private fun tokenMatches(candidate: JsonNode?): Boolean = - candidate != null && - candidate.isTextual && - MessageDigest.isEqual(candidate.asText().toByteArray(), token.toByteArray()) + private fun tokenMatches(candidate: JsonNode?): Boolean { + if (candidate == null || !candidate.isTextual) { + return false + } + + return MessageDigest.isEqual(candidate.asText().toByteArray(), token.toByteArray()) + } private fun Writer.line(message: JsonNode) { write(MAPPER.writeValueAsString(message)) @@ -171,6 +187,7 @@ public class BridgeHub( private fun newToken(): String { val bytes = ByteArray(16) SecureRandom().nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt index e4dedc88..5fd82a76 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt @@ -18,6 +18,7 @@ public class BridgeJar internal constructor( val resource = BridgeJar::class.java.getResourceAsStream("/$FILE_NAME") ?: error("The bridge plugin jar is missing from this build") + resource.use { it.readAllBytes() } }, ) @@ -31,12 +32,15 @@ public class BridgeJar internal constructor( val dir = overrideDir ?: home.resolve(if (target.id == 0) ".runelite" else ".rlcustom").resolve(SIDELOAD_DIR) val installed = dir.resolve(FILE_NAME) val wanted = embedded() + if (Files.isRegularFile(installed) && Files.readAllBytes(installed).contentEquals(wanted)) return installed Files.createDirectories(dir) + // The client loads every `.jar` in the directory, so the partial file must not look like one. val temp = Files.createTempFile(dir, FILE_NAME, ".tmp") Files.write(temp, wanted) Files.move(temp, installed, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + return installed } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt index e125855a..5048cd62 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -63,17 +63,20 @@ public class BridgeLink internal constructor( val id = ids.incrementAndGet() val reply = CompletableFuture() pending[id] = reply + try { // Checked after registering: a close that happens from here on fails this call through `pending`. if (closed.get()) throw closedError() val request = MAPPER.createObjectNode().put("id", id).put("op", op) request.set("args", args) + try { send(MAPPER.writeValueAsString(request)) } catch (e: IOException) { close() throw closedError() } + return reply.get(timeoutMs, TimeUnit.MILLISECONDS) } catch (e: TimeoutException) { throw BridgeError("timeout", "the client did not answer '$op' within $timeoutMs ms") @@ -108,6 +111,7 @@ public class BridgeLink internal constructor( val id = message?.get("id")?.asLong() ?: continue val reply = pending[id] ?: continue val err = message.get("err") + if (err != null) { val code = err.get("code")?.asText() ?: "internal" reply.completeExceptionally(BridgeError(code, err.get("message")?.asText().orEmpty())) @@ -122,9 +126,11 @@ public class BridgeLink internal constructor( } finally { closed.set(true) close() + for (reply in pending.values) { reply.completeExceptionally(closedError()) } + onClosed(this) } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt index 8f91ddd5..866191d9 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt @@ -77,10 +77,12 @@ public class PacketLog( lock.withLock { val seq = firstSeq + records.size records.addLast(PacketRecord(seq, login, cycle, origin, prot, System.currentTimeMillis(), text)) + if (records.size > capacity) { records.removeFirst() firstSeq++ } + appended.signalAll() seq } @@ -96,28 +98,34 @@ public class PacketLog( lock.withLock { val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) val matches = ArrayList() + // A cursor past the head can only come from another log; reading from the head keeps it usable. var scanned = query.after.seq.coerceIn(0, headSeq()) var dropped = 0L var timedOut = false + while (true) { if (scanned + 1 < firstSeq) { dropped += firstSeq - (scanned + 1) scanned = firstSeq - 1 } + while (scanned < headSeq() && matches.size < query.limit) { scanned++ val record = records[(scanned - firstSeq).toInt()] if (query.matches(record)) matches += record } + if (matches.isNotEmpty() || waitMs <= 0) break val remaining = deadline - System.nanoTime() if (remaining <= 0) { timedOut = true break } + appended.awaitNanos(remaining) } + PacketPage(matches, Cursor(scanned), Cursor(headSeq()), dropped, timedOut) } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt index cd641ff9..2ae4ef63 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -24,27 +24,43 @@ internal class PacketTap( override fun forSession(header: BinaryHeader): SessionMonitor = LoginTap(log, logins, settings, logins.nextEpoch()) - override fun onLogin(header: BinaryHeader) {} + override fun onLogin(header: BinaryHeader) { + // + } - override fun onLogout(header: BinaryHeader) {} + override fun onLogout(header: BinaryHeader) { + // + } - override fun onCacheUpdate(cacheProvider: CacheProvider) {} + override fun onCacheUpdate(cacheProvider: CacheProvider) { + // + } - override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + // + } - override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + // + } - override fun onNameUpdate(name: String) {} + override fun onNameUpdate(name: String) { + // + } override fun onUserInformationUpdate( userId: Long, userHash: Long, - ) {} + ) { + // + } override fun onTranscribe( cycle: Int, property: RootProperty, - ) {} + ) { + // + } } internal class LoginTap( @@ -74,6 +90,7 @@ internal class LoginTap( override fun onPacketDirection(direction: StreamDirection) { this.direction = direction + // The cache update arrives before the proxy knows whether a decoder exists for the revision, // so the first decoded packet is the earliest proof that a transcriber is hooked. if (!transcribing) { @@ -95,6 +112,7 @@ internal class LoginTap( // An exception here would unwind into the proxy's transcriber worker. "[${property.prot.lowercase()}] " } + log.append(epoch, cycle, origin, property.prot.uppercase(), text) } @@ -115,9 +133,15 @@ internal class LoginTap( override fun onUserInformationUpdate( userId: Long, userHash: Long, - ) {} + ) { + // + } - override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + // + } - override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) {} + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + // + } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt index ddd97c7e..06e8aed1 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt @@ -27,50 +27,74 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { override fun getActive(): PropertyFilterSet = AllEnabled - override fun setActive(index: Int) {} + override fun setActive(index: Int) { + // + } private object AllEnabled : PropertyFilterSet { override fun getCreationTime(): Long = 0 override fun getName(): String = "unfiltered" - override fun setName(name: String) {} + override fun setName(name: String) { + // + } - override fun deleteBackingFile() {} + override fun deleteBackingFile() { + // + } override fun get(filter: PropertyFilter): Boolean = true override fun set( filter: PropertyFilter, enabled: Boolean, - ) {} + ) { + // + } override fun set( category: ProtCategory, enabled: Boolean, - ) {} + ) { + // + } override fun set( streamDirection: StreamDirection, enabled: Boolean, - ) {} + ) { + // + } - override fun setAll(enabled: Boolean) {} + override fun setAll(enabled: Boolean) { + // + } - override fun setDefaults() {} + override fun setDefaults() { + // + } override fun getRegexFilters(): List = emptyList() - override fun addRegexFilter(regexFilter: RegexFilter) {} + override fun addRegexFilter(regexFilter: RegexFilter) { + // + } - override fun removeRegexFilter(regexFilter: RegexFilter) {} + override fun removeRegexFilter(regexFilter: RegexFilter) { + // + } override fun replaceRegexFilter( oldRegexFilter: RegexFilter, newRegexFilter: RegexFilter, - ) {} + ) { + // + } - override fun clearRegexFilters() {} + override fun clearRegexFilters() { + // + } } } @@ -110,36 +134,52 @@ internal object TapSettingSetStore : SettingSetStore { override fun getActive(): SettingSet = Fixed - override fun setActive(index: Int) {} + override fun setActive(index: Int) { + // + } private object Fixed : SettingSet { override fun getCreationTime(): Long = 0 override fun getName(): String = "mcp" - override fun setName(name: String) {} + override fun setName(name: String) { + // + } - override fun deleteBackingFile() {} + override fun deleteBackingFile() { + // + } override fun get(setting: Setting): Boolean = setting.enabled && setting !in off override fun set( setting: Setting, enabled: Boolean, - ) {} + ) { + // + } override fun set( category: SettingCategory, enabled: Boolean, - ) {} + ) { + // + } override fun set( group: SettingGroup, enabled: Boolean, - ) {} + ) { + // + } - override fun setAll(enabled: Boolean) {} + override fun setAll(enabled: Boolean) { + // + } - override fun setDefaults() {} + override fun setDefaults() { + // + } } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt index 405e2900..180e5441 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -70,6 +70,7 @@ internal class McpDispatcher( ): HttpReply { // A browser page on another site could otherwise reach this loopback server through DNS rebinding. if (origin != null && !isLocalOrigin(origin)) return HttpReply(403, null) + if (httpMethod != "POST") return HttpReply(405, null) val root = try { @@ -77,16 +78,21 @@ internal class McpDispatcher( } catch (e: JsonProcessingException) { return HttpReply(400, error(null, PARSE_ERROR, "Parse error")) } + if (root == null || !root.isObject) { return HttpReply(400, error(null, INVALID_REQUEST, "Expected one JSON-RPC object; batching is unsupported")) } + val id = root.get("id")?.takeUnless { it.isNull } val method = root.get("method")?.takeIf { it.isTextual }?.asText() + if (method == null) { val isResponse = root.has("result") || root.has("error") if (isResponse) return HttpReply(202, null) + return HttpReply(400, error(id, INVALID_REQUEST, "Missing method")) } + if (id == null) return HttpReply(202, null) val result = try { @@ -94,10 +100,12 @@ internal class McpDispatcher( } catch (e: RpcError) { return HttpReply(200, error(id, e.code, e.message.orEmpty())) } + val reply = MAPPER.createObjectNode() reply.put("jsonrpc", "2.0") reply.set("id", id) reply.set("result", result) + return HttpReply(200, MAPPER.writeValueAsString(reply)) } @@ -122,12 +130,14 @@ internal class McpDispatcher( .putObject("serverInfo") .put("name", "rsprox") .put("version", version) + return result } private fun listTools(): JsonNode { val result = MAPPER.createObjectNode() val list = result.putArray("tools") + for (tool in tools.values) { list .addObject() @@ -135,6 +145,7 @@ internal class McpDispatcher( .put("description", tool.description) .set("inputSchema", tool.inputSchema) } + return result } @@ -142,8 +153,10 @@ internal class McpDispatcher( val name = params?.get("name")?.takeIf { it.isTextual }?.asText() ?: throw RpcError(INVALID_PARAMS, "tools/call requires params.name") + val tool = tools[name] ?: throw RpcError(INVALID_PARAMS, "Unknown tool: $name") val arguments = params.get("arguments")?.takeUnless { it.isNull } ?: MAPPER.createObjectNode() + if (arguments !is ObjectNode) throw RpcError(INVALID_PARAMS, "params.arguments must be an object") val result = MAPPER.createObjectNode() val content = @@ -163,7 +176,9 @@ internal class McpDispatcher( result.put("isError", true) listOf(textBlock("internal: $t")) } + result.putArray("content").addAll(content) + return result } @@ -198,34 +213,41 @@ internal class McpDispatcher( arguments: ObjectNode, ) { val properties = tool.inputSchema.get("properties") ?: MAPPER.createObjectNode() + for (key in arguments.fieldNames()) { if (!properties.has(key)) { val allowed = properties.fieldNames().asSequence().joinToString(", ") throw ToolError("${tool.name}: unknown argument '$key'. Allowed: $allowed") } } + for (required in tool.inputSchema.get("required") ?: emptyList()) { if (!arguments.hasNonNull(required.asText())) { throw ToolError("${tool.name}: missing required argument '${required.asText()}'") } } + for ((key, value) in arguments.fields()) { val schema = properties.get(key) val type = schema.get("type").asText() + if (!value.hasType(type)) throw ToolError("${tool.name}: argument '$key' must be of type $type") val itemType = schema.get("items")?.get("type")?.asText() if (itemType != null && value.any { !it.hasType(itemType) }) { throw ToolError("${tool.name}: every item of '$key' must be of type $itemType") } + val allowed = schema.get("enum") if (allowed != null && allowed.none { it == value }) { val values = allowed.joinToString(", ") { it.asText() } throw ToolError("${tool.name}: argument '$key' must be one of $values") } + val minimum = schema.get("minimum")?.asLong() if (minimum != null && value.asLong() < minimum) { throw ToolError("${tool.name}: argument '$key' must be at least $minimum") } + val maximum = schema.get("maximum")?.asLong() if (maximum != null && value.asLong() > maximum) { throw ToolError("${tool.name}: argument '$key' must be at most $maximum") @@ -251,6 +273,7 @@ internal class McpDispatcher( } catch (e: URISyntaxException) { null } + return host in LOCAL_HOSTS } @@ -266,6 +289,7 @@ internal class McpDispatcher( .putObject("error") .put("code", code) .put("message", message) + return MAPPER.writeValueAsString(reply) } @@ -300,6 +324,7 @@ public class McpHttpServer( Executors.newCachedThreadPool { runnable -> Thread(runnable, "mcp-http").apply { isDaemon = true } } + server.createContext(PATH, ::handle) server.start() this.server = server @@ -309,8 +334,10 @@ public class McpHttpServer( try { if (exchange.requestURI.path != PATH) { exchange.sendResponseHeaders(404, -1) + return } + val body = exchange.requestBody.readAllBytes().toString(Charsets.UTF_8) val reply = dispatcher.handle( @@ -318,11 +345,15 @@ public class McpHttpServer( exchange.requestHeaders.getFirst("Origin"), body, ) + if (reply.status == 405) exchange.responseHeaders.add("Allow", "POST") + if (reply.body == null) { exchange.sendResponseHeaders(reply.status, -1) + return } + val bytes = reply.body.toByteArray(Charsets.UTF_8) exchange.responseHeaders.add("Content-Type", "application/json") exchange.sendResponseHeaders(reply.status, bytes.size.toLong()) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 2fdd9821..afe0e18c 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -85,6 +85,7 @@ public fun tools(sessions: () -> SessionManager): List = contains = args.text("contains"), limit = args.long("limit")?.toInt() ?: 200, ) + val page = sessions().resolve(args.text("session")).packets.read(query, args.long("wait_ms") ?: 0) ToolResult.Text(render(page)) }, @@ -208,6 +209,7 @@ private fun clientTool( val cursor = session.packets.head().seq val forwarded = args.deepCopy().without("session") val ok = session.requireLink().call(name.removePrefix("client_"), forwarded, timeoutMs(args)) + if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") result(ok.put("cursor", cursor)) } @@ -232,7 +234,9 @@ private fun render(page: PacketPage): String { "timedOut" to page.timedOut, "count" to page.packets.size, ) + val out = StringBuilder(McpDispatcher.MAPPER.writeValueAsString(meta)) + for (packet in page.packets) { out .append('\n') @@ -248,6 +252,7 @@ private fun render(page: PacketPage): String { .append(' ') .append(packet.text.replace("\n", "\n ")) } + return out.toString() } @@ -258,13 +263,16 @@ private fun schema( val schema = McpDispatcher.MAPPER.createObjectNode() schema.put("type", "object") val node = schema.putObject("properties") + for ((name, property) in properties) { node.set(name, property) } + if (required.isNotEmpty()) { val names = schema.putArray("required") required.forEach(names::add) } + return schema } @@ -273,6 +281,7 @@ private fun boolean(description: String): ObjectNode = property("boolean", descr private fun integerArray(description: String): ObjectNode { val node = property("array", description) node.putObject("items").put("type", "integer") + return node } @@ -281,10 +290,12 @@ private fun string( vararg allowed: String, ): ObjectNode { val node = property("string", description) + if (allowed.isNotEmpty()) { val values = node.putArray("enum") allowed.forEach(values::add) } + return node } @@ -295,13 +306,16 @@ private fun integer( ): ObjectNode { val node = property("integer", description) node.put("minimum", minimum) + if (maximum != null) node.put("maximum", maximum) + return node } private fun stringArray(description: String): ObjectNode { val node = property("array", description) node.putObject("items").put("type", "string") + return node } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index c14956cd..016bfc6a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -26,14 +26,19 @@ internal class ProxyServiceLauncher( override fun reserve(target: ProxyTargetConfig): Reservation { bridgeJar.installFor(target) + // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. var port: Int + do { port = service.allocatePort() } while (!isFree(port) || !isFree(HTTP_PORT_BASE + (port - basePort))) + val proxyTarget = service.initializeHttpServer(port, target) + return Reservation(port, proxyTarget.httpPort) { monitor -> service.launchRuneLiteClient(monitor, null, port, proxyTarget) + // Probing leaves a window in which another process can take the port. The proxy registers // a client type for a port only after it has bound it. check(isBound(port)) { "proxy port $port could not be bound" } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 74e12ce1..80cc4d6a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -173,11 +173,13 @@ public class Session internal constructor( lock.withLock { val before = client val after = reduce(before, event) + if (after !== before) { client = after mark(after) changed.signalAll() } + after } @@ -188,6 +190,7 @@ public class Session internal constructor( while (client is ClientState.Launching && remaining > 0) { remaining = changed.awaitNanos(remaining) } + client } @@ -209,6 +212,7 @@ public class Session internal constructor( is ClientState.Launching -> state.launch is ClientState.Connected -> state.launch } + return SessionSnapshot( session = id.value, target = target.name, @@ -238,6 +242,7 @@ public class Session internal constructor( is ClientState.Connected -> "CLIENT_CONNECTED" to "pid=${state.pid}" is ClientState.Stopped -> "CLIENT_EXITED" to state.reason } + packets.append(logins.current()?.epoch ?: 0, 0, Origin.PROXY, prot, text) } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index e6e8f054..51cf020a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -57,23 +57,31 @@ public class SessionManager internal constructor( if (existing != null && target != null && !existing.target.name.equals(target, ignoreCase = true)) { throw ToolError("session ${existing.id} belongs to target '${existing.target.name}'") } + val current = existing ?: Session(SessionId("s${sessions.size + 1}"), resolveTarget(target)) if (current.client is ClientState.Stopped) launch(current) current } + started.awaitConnected(waitMs) + return started.snapshot() } /** Kills the client. The session and its packets stay listed and readable. Idempotent. */ public fun stop(session: String?): SessionSnapshot { val resolved = resolve(session) + synchronized(launchLock) { val state = resolved.client + // Stopped first, so the close of the link below is not mistaken for the client exiting. resolved.apply(SessionEvent.Stop("stopped by caller")) + when (state) { - is ClientState.Stopped -> {} + is ClientState.Stopped -> { + // + } is ClientState.Launching -> launcher.kill(state.launch.proxyPort) is ClientState.Connected -> { state.link.close() @@ -81,6 +89,7 @@ public class SessionManager internal constructor( } } } + return resolved.snapshot() } @@ -92,6 +101,7 @@ public class SessionManager internal constructor( return sessions.firstOrNull { it.id.value == ref } ?: throw ToolError("no session '$ref'. Sessions: ${sessionIds()}") } + return sessions.singleOrNull() ?: throw ToolError( if (sessions.isEmpty()) { @@ -108,10 +118,12 @@ public class SessionManager internal constructor( private fun resolveTarget(name: String?): ProxyTargetConfig { val targets = launcher.targets() + if (name == null) { // Target 0 is the official game; a custom target is what a headless caller is here to test. return targets.firstOrNull { it.id != 0 } ?: targets.first() } + return targets.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: throw ToolError("no target '$name'. Targets: ${targets.joinToString(", ") { it.name }}") } @@ -120,15 +132,19 @@ public class SessionManager internal constructor( if (hungLaunch) { throw ToolError("an earlier launch never completed its handshake; restart the rsprox MCP process") } + val reservation = try { launcher.reserve(session.target) } catch (e: Exception) { throw ToolError("could not prepare target '${session.target.name}': ${rootMessage(e)}") } + val launch = Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort, System.currentTimeMillis()) + sessions.addIfAbsent(session) + // Registered before the client is forked: its hello can arrive before the launch call returns. bridge.expect(launch.httpPort, listener(session, launch)) session.apply(SessionEvent.Launched(launch)) @@ -143,6 +159,7 @@ public class SessionManager internal constructor( failure.set(t) } }, "mcp-launch-${session.id}") + thread.isDaemon = true thread.start() thread.join(launchTimeoutMs) @@ -153,6 +170,7 @@ public class SessionManager internal constructor( } else { failure.get()?.let(::rootMessage) ?: return } + launcher.kill(launch.proxyPort) session.apply(SessionEvent.Stop(reason)) throw ToolError("session ${session.id} failed to launch: $reason") @@ -170,12 +188,14 @@ public class SessionManager internal constructor( pid: Long, ): Boolean { val state = session.apply(SessionEvent.Hello(launch.httpPort, link, pid)) + return state is ClientState.Connected && state.link === link } override fun onClosed(link: BridgeLink) { val before = session.client if (session.apply(SessionEvent.LinkClosed(link)) === before) return + // The client is gone, or cannot be driven any more. Either way the proxy still holds // its process handle and session monitor for the port. synchronized(launchLock) { launcher.kill(launch.proxyPort) } @@ -184,6 +204,7 @@ public class SessionManager internal constructor( private fun rootMessage(throwable: Throwable): String { val root = generateSequence(throwable) { it.cause }.last() + return root.message ?: root.toString() } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index c977c130..f422a494 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -41,6 +41,7 @@ class BridgeHubTest { hub.expect(43650, listener) val plugin = dial(43650) assertEquals(1, plugin.read()?.get("welcome")?.asInt()) + return plugin to listener.hello.get(10, TimeUnit.SECONDS).first } @@ -48,6 +49,7 @@ class BridgeHubTest { private fun bridgeError(call: CompletableFuture<*>): BridgeError { val failure = assertFailsWith { call.get(10, TimeUnit.SECONDS) } + return failure.cause as BridgeError } @@ -58,6 +60,7 @@ class BridgeHubTest { assertTrue(file.get("port").asInt() > 0) assertEquals(22, file.get("token").asText().length) assertEquals(ProcessHandle.current().pid(), file.get("pid").asLong()) + if (Files.getFileStore(fixture.rendezvous).supportsFileAttributeView("posix")) { assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(fixture.rendezvous))) } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index 3c2da687..f735ac9a 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -25,6 +25,7 @@ internal fun awaitTrue( condition: () -> Boolean, ) { val deadline = System.nanoTime() + 10_000_000_000 + while (!condition()) { check(System.nanoTime() < deadline) { "timed out waiting until $what" } Thread.sleep(5) @@ -58,6 +59,7 @@ internal class RecordingListener( pid: Long, ): Boolean { hello.complete(link to pid) + return accept } @@ -121,7 +123,9 @@ internal class FakePlugin private constructor( .put("httpPort", httpPort) .put("token", token ?: file.get("token").asText()) .put("pid", pid) + plugin.send(mapper.writeValueAsString(hello)) + return plugin } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt index 5a35bb38..e6ef478e 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt @@ -110,6 +110,7 @@ class PacketLogTest { CompletableFuture.supplyAsync { log.read(PacketQuery(after = Cursor(1), prots = setOf("WANTED")), waitMs = 30_000) } + log.add("NOISE") assertFalse(read.isDone) log.add("WANTED") diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt index 85047f2f..22b9d167 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt @@ -53,6 +53,7 @@ class McpDispatcherTest { val node = mapper.readTree(reply.body) assertEquals("2.0", node.get("jsonrpc").asText()) assertNull(node.get("error"), "unexpected error: ${reply.body}") + return node.get("result") } @@ -68,6 +69,7 @@ class McpDispatcherTest { fun `initialize echoes a supported protocol version and names the server`() { val result = result("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}""") + assertEquals("2025-06-18", result.get("protocolVersion").asText()) assertTrue(result.get("capabilities").has("tools")) assertEquals("rsprox", result.get("serverInfo").get("name").asText()) @@ -78,6 +80,7 @@ class McpDispatcherTest { fun `initialize answers an unknown protocol version with the newest supported one`() { val result = result("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}""") + assertEquals("2025-11-25", result.get("protocolVersion").asText()) } @@ -136,6 +139,7 @@ class McpDispatcherTest { """{"text":"a","mode":"c"}""" to "'mode' must be one of a, b", """{"text":"a","tags":["x",1]}""" to "every item of 'tags' must be of type string", ) + for ((arguments, expected) in cases) { val result = callTool("echo", arguments) assertTrue(result.get("isError").asBoolean(), arguments) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt index c6adffb3..bcfc9492 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt @@ -41,6 +41,7 @@ class ToolsTest { arguments: String = "{}", ): JsonNode { val body = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$name","arguments":$arguments}}""" + return mapper.readTree(dispatcher.handle("POST", null, body).body).get("result") } @@ -50,6 +51,7 @@ class ToolsTest { ): String { val result = call(name, arguments) assertFalse(result.get("isError").asBoolean(), result.toString()) + return result.get("content")[0].get("text").asText() } @@ -149,10 +151,12 @@ class ToolsTest { val forwarded = ArrayList>() connect { op, args -> forwarded += op to args + // What the action causes arrives while the call is in flight. manager.resolve("s1").packets.append(1, 5, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"hello\"") """"ok":{"gameState":"LOGIN_SCREEN","tick":0}""" } + val log = manager.resolve("s1").packets val before = log.head().seq @@ -244,12 +248,14 @@ class ToolsTest { val forwarded = ArrayList>() connect { op, args -> forwarded += op to args + when (op) { "login" -> """"ok":{"gameState":"LOGGED_IN"}""" "click" -> """"ok":{"x":380,"y":215}""" else -> """"ok":{"typed":8}""" } } + val cursor = manager.resolve("s1").packets.head().seq assertEquals( diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt index 9507784e..733cc8dc 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt @@ -33,6 +33,7 @@ internal class FakeLauncher( override fun reserve(target: ProxyTargetConfig): Reservation { reserve.invoke(target) reserved += target + return Reservation(43750 + reserved.size, 43649 + reserved.size) { launch() } } @@ -64,6 +65,7 @@ class SessionManagerTest { val plugin = dial(43650) assertEquals("s1", plugin.read()?.get("session")?.asText()) assertEquals("connected", manager.start(null, "s1", 10_000).state) + return plugin } @@ -170,6 +172,7 @@ class SessionManagerTest { fun `a launch that never returns is abandoned and blocks further launches`() { val release = CountDownLatch(1) launcher.launch = { release.await() } + try { val error = assertFailsWith { manager.start(null, null, 0) } assertEquals("session s1 failed to launch: launcher never completed its handshake", error.message) @@ -177,10 +180,12 @@ class SessionManagerTest { assertEquals(listOf(43751), launcher.killed) launcher.launch = {} + for (session in listOf("s1", null)) { val refused = assertFailsWith { manager.start(null, session, 0) } assertTrue(refused.message!!.contains("restart the rsprox MCP process"), refused.message) } + assertEquals(1, launcher.reserved.size) } finally { release.countDown() diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java index f1902fe4..5918151b 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -61,19 +61,24 @@ static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { if (!Files.isRegularFile(rendezvous)) { return null; } + int httpPort = httpPortFromCommandLine(); if (httpPort < 0) { return null; } + Socket socket = new Socket(); + try { JsonObject file = gson.fromJson(Files.readString(rendezvous), JsonObject.class); socket.connect( new InetSocketAddress(InetAddress.getLoopbackAddress(), file.get("port").getAsInt()), CONNECT_TIMEOUT_MS); + socket.setSoTimeout(HELLO_TIMEOUT_MS); BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)); @@ -90,25 +95,32 @@ static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { String line = reader.readLine(); JsonObject answer = line == null ? null : gson.fromJson(line, JsonObject.class); + if (answer == null || !answer.has("welcome")) { log.info("rsprox MCP bridge stays dormant: {}", answer == null ? "no answer" : answer.get("reject")); socket.close(); + return null; } + socket.setSoTimeout(0); boolean softwareRendering = answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); + BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, softwareRendering); connection.start(); log.info("rsprox MCP bridge connected as session {}", answer.get("session")); + return connection; } catch (IOException | RuntimeException e) { log.info("rsprox MCP bridge stays dormant: {}", e.toString()); + try { socket.close(); } catch (IOException ignored) { // Nothing was established, so there is nothing to report. } + return null; } } @@ -122,14 +134,17 @@ boolean softwareRendering() { private static int httpPortFromCommandLine() { String command = System.getProperty("sun.java.command", ""); int start = command.indexOf(JAV_CONFIG_PREFIX); + if (start < 0) { return -1; } + start += JAV_CONFIG_PREFIX.length(); int end = start; while (end < command.length() && Character.isDigit(command.charAt(end))) { end++; } + try { return Integer.parseInt(command.substring(start, end)); } catch (NumberFormatException e) { @@ -145,12 +160,14 @@ private void start() { private static Thread daemon(Runnable runnable, String name) { Thread thread = new Thread(runnable, name); thread.setDaemon(true); + return thread; } private void readLoop() { try { String line; + while ((line = reader.readLine()) != null) { JsonObject request = gson.fromJson(line, JsonObject.class); long id = request.get("id").getAsLong(); @@ -170,6 +187,7 @@ private void readLoop() { private void answer(long id, String op, JsonObject args) { JsonObject reply = new JsonObject(); reply.addProperty("id", id); + try { reply.add("ok", ops.run(op, args)); } catch (BridgeException e) { @@ -178,6 +196,7 @@ private void answer(long id, String op, JsonObject args) { log.warn("rsprox MCP bridge op {} failed", op, t); reply.add("err", error("internal", t.toString())); } + try { synchronized (writer) { writer.write(gson.toJson(reply)); @@ -193,6 +212,7 @@ private static JsonObject error(String code, String message) { JsonObject error = new JsonObject(); error.addProperty("code", code); error.addProperty("message", message); + return error; } @@ -201,11 +221,13 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } + try { socket.close(); } catch (IOException ignored) { // The connection is being dropped either way. } + workers.shutdownNow(); } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java index f9d52e2a..2202a3e2 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java @@ -46,6 +46,7 @@ T read(ClientCall body) throws BridgeException { result.completeExceptionally(t); } }); + return await(result, "the client thread"); } @@ -72,15 +73,18 @@ BufferedImage frame() throws BridgeException { result.completeExceptionally(t); } }); + return await(result, "a rendered frame"); } private static BufferedImage copy(Image image) { BufferedImage copy = new BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = copy.createGraphics(); graphics.drawImage(image, 0, 0, null); graphics.dispose(); + return copy; } @@ -97,6 +101,7 @@ private static T await(CompletableFuture result, String what) throws Brid if (cause instanceof BridgeException) { throw (BridgeException) cause; } + throw new BridgeException("internal", String.valueOf(cause)); } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index 0108dd0d..f202e34b 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -42,6 +42,7 @@ public class McpBridgePlugin extends Plugin { protected void startUp() { Path rendezvous = Paths.get(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"); Ops ops = new Ops(new GameAccess(client, clientThread, drawManager)); + // The protocol spells out an absent player as null, which the client's Gson would drop. connection = BridgeConnection.dial(rendezvous, gson.newBuilder().serializeNulls().create(), ops); if (connection != null && connection.softwareRendering()) { @@ -57,25 +58,32 @@ protected void startUp() { */ private void stopGpuPlugin() { Plugin gpu = null; + for (Plugin plugin : pluginManager.getPlugins()) { if (plugin.getClass().getSimpleName().equals(GPU_PLUGIN)) { gpu = plugin; } } + if (gpu == null || !pluginManager.isPluginEnabled(gpu)) { return; } + // The GPU plugin finishes starting on the client thread, some time after it is marked active. // Stopping it before then lets that late start win, so wait until the client renders through it. long deadline = System.currentTimeMillis() + GPU_START_WAIT_MS; + try { while (!(pluginManager.isPluginActive(gpu) && client.isGpu())) { if (System.currentTimeMillis() >= deadline) { log.info("The GPU plugin did not take over rendering, so there is nothing to stop"); + return; } + Thread.sleep(GPU_POLL_MS); } + pluginManager.stopPlugin(gpu); log.info("Stopped the GPU plugin for software rendering"); } catch (InterruptedException e) { diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index c8f9045f..0023e078 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -63,6 +63,7 @@ JsonElement run(String op, JsonObject args) throws BridgeException { if (handler == null) { throw new BridgeException("bad_args", "unknown op '" + op + "'"); } + return handler.run(args); } @@ -86,9 +87,11 @@ private JsonElement state(JsonObject args) throws BridgeException { described.addProperty("plane", at.getPlane()); out.add("player", described); } + JsonObject menu = new JsonObject(); menu.addProperty("open", client.isMenuOpen()); JsonArray entries = new JsonArray(); + for (MenuEntry entry : client.getMenu().getMenuEntries()) { JsonObject described = new JsonObject(); described.addProperty("option", entry.getOption()); @@ -99,8 +102,10 @@ private JsonElement state(JsonObject args) throws BridgeException { described.addProperty("p1", entry.getParam1()); entries.add(described); } + menu.add("entries", entries); out.add("menu", menu); + return out; }); } @@ -111,24 +116,31 @@ private JsonElement widgets(JsonObject args) throws BridgeException { String needle = text == null ? null : text.toLowerCase(Locale.ROOT); boolean includeHidden = optionalBoolean(args, "hidden"); Integer limit = optionalInt(args, "limit"); + return game.read(client -> { WidgetWalk walk = new WidgetWalk(group, needle, includeHidden, limit == null ? DEFAULT_WIDGET_LIMIT : limit); + Set roots = new LinkedHashSet<>(); + for (Widget root : client.getWidgetRoots()) { if (root != null) { roots.add(root.getId() >>> 16); walk.visit(root, 0); } } + JsonObject out = new JsonObject(); JsonArray rootGroups = new JsonArray(); + for (int root : roots) { rootGroups.add(root); } + out.add("roots", rootGroups); out.add("widgets", walk.found); out.addProperty("truncated", walk.truncated); + return out; }); } @@ -153,20 +165,25 @@ void visit(Widget widget, int depth) { if (widget == null || truncated || depth > WIDGET_DEPTH_LIMIT) { return; } + boolean hidden = widget.isHidden(); if (hidden && !includeHidden) { return; } + if (group == null || widget.getId() >>> 16 == group) { JsonObject described = describe(widget, hidden); if (described != null) { if (found.size() >= limit) { truncated = true; + return; } + found.add(described); } } + visitAll(widget.getStaticChildren(), depth); visitAll(widget.getDynamicChildren(), depth); visitAll(widget.getNestedChildren(), depth); @@ -185,6 +202,7 @@ private JsonObject describe(Widget widget, boolean hidden) { String name = nullToEmpty(widget.getName()); JsonArray actions = new JsonArray(); String[] raw = widget.getActions(); + if (raw != null) { for (String action : raw) { if (action != null && !action.isEmpty()) { @@ -192,12 +210,15 @@ private JsonObject describe(Widget widget, boolean hidden) { } } } + if (text.isEmpty() && name.isEmpty() && actions.size() == 0) { return null; } + if (needle != null && !contains(text) && !contains(name) && !contains(actions.toString())) { return null; } + Rectangle bounds = widget.getBounds(); JsonObject out = new JsonObject(); out.addProperty("id", ref(widget)); @@ -208,6 +229,7 @@ private JsonObject describe(Widget widget, boolean hidden) { out.add("click", ints(centre(bounds).x, centre(bounds).y)); out.addProperty("type", widget.getType()); out.addProperty("hidden", hidden); + return out; } @@ -221,12 +243,14 @@ private JsonElement vars(JsonObject args) throws BridgeException { int[] varbits = optionalInts(args, "varbits"); int[] varcInts = optionalInts(args, "varcInts"); int[] varcStrs = optionalInts(args, "varcStrs"); + return game.read(client -> { JsonObject out = new JsonObject(); out.add("varps", readVars("varp", varps, id -> number(client.getVarpValue(id)))); out.add("varbits", readVars("varbit", varbits, id -> number(client.getVarbitValue(id)))); out.add("varcInts", readVars("varcInt", varcInts, id -> number(client.getVarcIntValue(id)))); out.add("varcStrs", readVars("varcStr", varcStrs, id -> string(client.getVarcStrValue(id)))); + return out; }); } @@ -237,6 +261,7 @@ private interface VarReader { private static JsonObject readVars(String kind, int[] ids, VarReader reader) throws BridgeException { JsonObject out = new JsonObject(); + for (int id : ids) { try { out.add(Integer.toString(id), reader.read(id)); @@ -245,6 +270,7 @@ private static JsonObject readVars(String kind, int[] ids, VarReader reader) thr throw new BridgeException("not_found", kind + " " + id + " could not be read: " + e); } } + return out; } @@ -253,12 +279,15 @@ private JsonElement screenshot(JsonObject args) throws BridgeException { int[] canvas = game.read(client -> new int[] {client.getCanvas().getWidth(), client.getCanvas().getHeight()}); int width = canvas[0]; int height = canvas[1]; + if (width <= 0 || height <= 0) { throw new BridgeException("wrong_state", "the canvas has no size yet"); } + // A high-density display renders more pixels than the canvas has units. Clicks are in canvas // units, so the image is brought to canvas size to make a pixel and a click coordinate the same thing. BufferedImage sized = frame; + if (frame.getWidth() != width || frame.getHeight() != height) { sized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D graphics = sized.createGraphics(); @@ -266,29 +295,36 @@ private JsonElement screenshot(JsonObject args) throws BridgeException { graphics.drawImage(frame, 0, 0, width, height, null); graphics.dispose(); } + ByteArrayOutputStream png = new ByteArrayOutputStream(); + try { ImageIO.write(sized, "png", png); } catch (IOException e) { throw new BridgeException("internal", "could not encode the frame: " + e); } + JsonObject out = new JsonObject(); out.addProperty("png", Base64.getEncoder().encodeToString(png.toByteArray())); out.addProperty("width", width); out.addProperty("height", height); + return out; } private JsonElement click(JsonObject args) throws BridgeException { String button = optionalString(args, "button"); boolean right = "right".equals(button); + if (button != null && !right && !"left".equals(button)) { throw new BridgeException("bad_args", "button must be \"left\" or \"right\""); } + String widget = optionalString(args, "widget"); Integer x = optionalInt(args, "x"); Integer y = optionalInt(args, "y"); Point point; + if (widget != null) { point = game.read(client -> centre(visibleBounds(client, widget))); } else if (x != null && y != null) { @@ -296,22 +332,28 @@ private JsonElement click(JsonObject args) throws BridgeException { } else { throw new BridgeException("bad_args", "pass either x and y, or widget"); } + int awtButton = right ? MouseEvent.BUTTON3 : MouseEvent.BUTTON1; int downMask = right ? InputEvent.BUTTON3_DOWN_MASK : InputEvent.BUTTON1_DOWN_MASK; game.input(canvas -> { long now = System.currentTimeMillis(); canvas.dispatchEvent( new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, now, 0, point.x, point.y, 0, false)); + canvas.dispatchEvent( new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, now, downMask, point.x, point.y, 1, false, awtButton)); + canvas.dispatchEvent( new MouseEvent(canvas, MouseEvent.MOUSE_RELEASED, now + 30, 0, point.x, point.y, 1, false, awtButton)); + canvas.dispatchEvent( new MouseEvent(canvas, MouseEvent.MOUSE_CLICKED, now + 30, 0, point.x, point.y, 1, false, awtButton)); }); + JsonObject out = new JsonObject(); out.addProperty("x", point.x); out.addProperty("y", point.y); + return out; } @@ -320,17 +362,21 @@ private static Rectangle visibleBounds(Client client, String ref) throws BridgeE if (!matcher.matches()) { throw new BridgeException("bad_args", "widget must look like \"558:7\" or \"558:7[3]\""); } + Widget widget = client.getWidget(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); if (widget != null && matcher.group(3) != null) { widget = widget.getChild(Integer.parseInt(matcher.group(3))); } + if (widget == null) { throw new BridgeException("not_found", "widget " + ref + " does not exist"); } + Rectangle bounds = widget.getBounds(); if (widget.isHidden() || bounds == null || bounds.isEmpty()) { throw new BridgeException("not_found", "widget " + ref + " is not visible"); } + return bounds; } @@ -339,17 +385,21 @@ private JsonElement type(JsonObject args) throws BridgeException { if (text == null) { throw new BridgeException("bad_args", "text is required"); } + boolean enter = optionalBoolean(args, "enter"); game.input(canvas -> { for (char ch : text.toCharArray()) { key(canvas, KeyEvent.getExtendedKeyCodeForChar(ch), ch); } + if (enter) { key(canvas, KeyEvent.VK_ENTER, '\n'); } }); + JsonObject out = new JsonObject(); out.addProperty("typed", text.length()); + return out; } @@ -365,10 +415,12 @@ private JsonElement login(JsonObject args) throws BridgeException { if (username == null) { throw new BridgeException("bad_args", "username is required"); } + String password = optionalString(args, "password"); if (password == null || password.isEmpty()) { throw new BridgeException("bad_args", "password is required and must not be empty"); } + Integer wait = optionalInt(args, "wait_ms"); long deadline = System.currentTimeMillis() + (wait == null ? DEFAULT_LOGIN_WAIT_MS : wait); awaitLoginScreen(deadline); @@ -377,24 +429,31 @@ private JsonElement login(JsonObject args) throws BridgeException { if (state != GameState.LOGIN_SCREEN) { throw new BridgeException("wrong_state", "the client is not on the login screen: " + state); } + client.setUsername(username); client.setPassword(password); client.setGameState(GameState.LOGGING_IN); + return null; }); + while (true) { GameState state = game.read(Client::getGameState); if (state == GameState.LOGGED_IN) { JsonObject out = new JsonObject(); out.addProperty("gameState", state.name()); + return out; } + if (state == GameState.LOGIN_SCREEN) { throw new BridgeException("wrong_state", "the login was refused; the client is back on the login screen"); } + if (System.currentTimeMillis() >= deadline) { throw new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); } + try { Thread.sleep(LOGIN_POLL_MS); } catch (InterruptedException e) { @@ -411,12 +470,14 @@ private void awaitLoginScreen(long deadline) throws BridgeException { if (state != GameState.STARTING && state != GameState.UNKNOWN) { return; } + if (System.currentTimeMillis() >= deadline) { throw new BridgeException( "timeout", "the client did not reach the login screen; gameState is " + state + ". A client that cannot reach the game server stays in this state."); } + try { Thread.sleep(LOGIN_POLL_MS); } catch (InterruptedException e) { @@ -432,14 +493,17 @@ private static Point centre(Rectangle bounds) { private static String ref(Widget widget) { String ref = (widget.getId() >>> 16) + ":" + (widget.getId() & 0xFFFF); + return widget.getIndex() >= 0 ? ref + "[" + widget.getIndex() + "]" : ref; } private static JsonArray ints(int... values) { JsonArray out = new JsonArray(); + for (int value : values) { out.add(value); } + return out; } @@ -457,6 +521,7 @@ private static String nullToEmpty(String value) { private static JsonElement present(JsonObject args, String name) { JsonElement value = args.get(name); + return value == null || value.isJsonNull() ? null : value; } @@ -465,9 +530,11 @@ private static Integer optionalInt(JsonObject args, String name) throws BridgeEx if (value == null) { return null; } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { throw new BridgeException("bad_args", name + " must be an integer"); } + return value.getAsInt(); } @@ -476,9 +543,11 @@ private static String optionalString(JsonObject args, String name) throws Bridge if (value == null) { return null; } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { throw new BridgeException("bad_args", name + " must be a string"); } + return value.getAsString(); } @@ -487,9 +556,11 @@ private static boolean optionalBoolean(JsonObject args, String name) throws Brid if (value == null) { return false; } + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { throw new BridgeException("bad_args", name + " must be a boolean"); } + return value.getAsBoolean(); } @@ -498,18 +569,23 @@ private static int[] optionalInts(JsonObject args, String name) throws BridgeExc if (value == null) { return new int[0]; } + if (!value.isJsonArray()) { throw new BridgeException("bad_args", name + " must be an array of integers"); } + JsonArray array = value.getAsJsonArray(); int[] out = new int[array.size()]; + for (int i = 0; i < out.length; i++) { JsonElement item = array.get(i); if (!item.isJsonPrimitive() || !item.getAsJsonPrimitive().isNumber()) { throw new BridgeException("bad_args", name + " must be an array of integers"); } + out[i] = item.getAsInt(); } + return out; } } From 03f71ce0161645fcdc2cc151672467033c5f32b7 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 17:17:45 +0200 Subject: [PATCH 14/72] refactor(mcp): give each default one owner and test both halves together Review pass over the module and the plugin. Defaults that were typed on both sides of the bridge, and again in tool descriptions, now have one owner: the server always forwards the login wait and the widget limit, and descriptions read their defaults from the constants. Names say what the code does: GameAccess.read also wrote credentials, so it is onClientThread, and the launcher's port checks are canBind and hasClientType. Nested conditions in the plugin and the session manager become guard clauses, login is split into submitting credentials and waiting, and the software rendering flag is an enum in the constructors. The plugin no longer re-checks arguments the server already validates. The plugin had no automated tests. The new end-to-end tests run the real bridge hub against the real plugin code over a loopback socket, with only the game faked. To allow that, GameAccess takes the client thread runner as an argument and BridgeConnection.dial takes the port from its caller. Further tests cover the MCP endpoint over real HTTP, the packet tap, and the launcher's port probing. The reducer cases that the session manager tests already prove are removed. The README no longer lists every tool. It points at tools/list, so the descriptions in the code are the one place a tool is documented, and a test checks that each client tool has a matching op in the plugin. Tests run headless with user.home in a temporary directory, so they cannot read or write the real ~/.rsprox. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 130 +++-- mcp/build.gradle.kts | 28 + mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 7 +- .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 32 +- .../kotlin/net/rsprox/mcp/bridge/BridgeJar.kt | 2 +- .../net/rsprox/mcp/bridge/BridgeLink.kt | 3 +- .../net/rsprox/mcp/packets/PacketLog.kt | 8 +- .../net/rsprox/mcp/server/McpHttpServer.kt | 4 + .../kotlin/net/rsprox/mcp/server/Tools.kt | 38 +- .../mcp/session/ProxyServiceLauncher.kt | 34 +- .../net/rsprox/mcp/session/SessionManager.kt | 24 +- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 20 +- .../net/rsprox/mcp/bridge/FakePlugin.kt | 4 +- .../net/rsprox/mcp/packets/PacketTapTest.kt | 230 ++++++++ .../rsprox/mcp/server/McpHttpServerTest.kt | 132 +++++ .../net/rsprox/mcp/server/MinimalArguments.kt | 23 + .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 77 ++- .../mcp/session/ProxyServiceLauncherTest.kt | 30 + .../rsprox/mcp/session/SessionManagerTest.kt | 25 + .../net/rsprox/mcp/session/SessionTest.kt | 43 +- .../rsprox/mcpbridge/BridgeEndToEndTest.kt | 522 ++++++++++++++++++ .../kotlin/net/rsprox/mcpbridge/FakeGame.kt | 282 ++++++++++ .../rsprox/mcpbridge/BridgeConnection.java | 35 +- .../java/net/rsprox/mcpbridge/GameAccess.java | 10 +- .../net/rsprox/mcpbridge/McpBridgePlugin.java | 26 +- .../main/java/net/rsprox/mcpbridge/Ops.java | 159 +++--- .../java/net/rsprox/mcpbridge/Rendering.java | 7 + 27 files changed, 1617 insertions(+), 318 deletions(-) create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java diff --git a/mcp/README.md b/mcp/README.md index 01f052ca..283dcc06 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -7,7 +7,8 @@ checking a private server end to end with a real client. The server has two halves. The proxy half runs in this process and records every packet. The client half is a small RuneLite plugin that the server installs into the client's sideload directory before -each launch. The plugin connects back to the server over loopback. +each launch. The plugin connects back to the server over loopback. The proxy only observes, so it +cannot inject or change packets. All input goes through the client as mouse and key events. ## Run it @@ -16,7 +17,11 @@ each launch. The plugin connects back to the server over loopback. ``` The server listens on `http://127.0.0.1:43580/mcp`. It uses the proxy targets that the rsprox GUI -uses, from `~/.rsprox/proxy-targets.yaml`. The GUI may run at the same time. +uses, from `~/.rsprox/proxy-targets.yaml`. The GUI may run at the same time. A session records to the +usual folder of its target, as it does when launched from the GUI. + +Each client opens a window, so the machine needs a display. A machine without one can use a virtual +display, as described under "Run it without a display". Options go in `--args`: @@ -34,12 +39,15 @@ Options go in `--args`: The plugin is installed as `rsprox-mcp-bridge.jar` in `~/.rlcustom/sideloaded-plugins` for a custom target and in `~/.runelite/sideloaded-plugins` for the official one. A client that the MCP server did -not launch loads the plugin too, and the plugin then does nothing. +not launch loads the plugin too, and the plugin then does nothing. The plugin finds the server through +the rendezvous file `~/.rsprox/mcp/bridge.json`. Every server of one user account writes that same +file, so run one MCP server per user account. ## Run it without a display -RuneLite always opens a window, so on a machine with no screen give it a virtual one. On Linux, run -the server under Xvfb and pass `--software-rendering`: +RuneLite always opens a window, so on a machine with no screen give it a virtual one. macOS and +Windows have no virtual display, so there the client needs a real screen. On Linux, run the server +under Xvfb and pass `--software-rendering`: ``` xvfb-run -s "-screen 0 1280x800x24" ./gradlew :mcp:run --args="--software-rendering" @@ -57,12 +65,9 @@ or Ubuntu: apt-get install xvfb libxrender1 libxtst6 libxi6 libxext6 libfontconfig1 libfreetype6 fonts-dejavu-core ``` -The client reports that audio is unavailable when the machine has no sound device. That is harmless. - -A custom target reaches each world through a loopback address such as `127.0.1.3`. Linux routes all -of `127.0.0.0/8` to loopback, so it needs no alias. - -There is no virtual display on macOS or Windows, so there the client needs a real screen. +The machine needs no sound device. Without one the client reports that audio is unavailable and +carries on. It needs no loopback alias either. A custom target reaches each world through a loopback +address such as `127.0.1.3`, and Linux routes all of `127.0.0.0/8` to loopback. ## Register it with an MCP client @@ -83,32 +88,35 @@ it runs, whatever the clients do. ## Tools -`session` is optional on every tool while only one session exists. +The tools come in three groups. The `session_*` tools launch a client for a target, stop it and list +the sessions. `packets_read` reads the decoded packets of a session after a cursor, and can wait for +the first packet that matches. The `client_*` tools look at the client and drive it: they read its +state, widgets and variables, take a screenshot, log in, click and type. -| Tool | What it does | -|---|---| -| `session_start` | Launches a client for a target, or relaunches a stopped session. Waits until the client plugin has connected. | -| `session_stop` | Kills the client of a session. The session and its packets stay readable. | -| `session_list` | Lists the sessions with their state, ports, login and packet cursor, and the target names. | -| `packets_read` | Reads decoded packets after a cursor, unfiltered, in both directions. With `wait_ms` it waits for the first match. | -| `client_state` | Reads the game state, tick, canvas size, world, local player and right-click menu. | -| `client_screenshot` | Returns the game canvas as a PNG. Image pixels are click coordinates. | -| `client_widgets` | Lists the interface widgets that have text, a name or actions, each with its bounds and click point. | -| `client_vars` | Reads varps, varbits and client variables by id. | -| `client_login` | Logs in from the login screen and waits until the client is in the game. | -| `client_click` | Clicks at canvas coordinates or at the centre of a widget. | -| `client_type` | Types text as key presses, with an optional Enter. | +The server describes each tool itself. `tools/list` returns every tool with its description and the +schema of its arguments: + +``` +curl -s http://127.0.0.1:43580/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +`session` is optional on every tool while only one session exists. Every `client_*` result carries `cursor`, the packet cursor taken just before the call. Pass it as `after` to `packets_read` to see only what the action caused. Each packet is one line: ` L T `. `C` is client to server, `S` is server to client, and `P` is a marker that rsprox adds (`CLIENT_LAUNCHED`, `CLIENT_CONNECTED`, -`CLIENT_EXITED`, `LOGIN`, `LOGOUT`). +`CLIENT_EXITED`, `LOGIN`, `LOGOUT`). The key codes of typed text are zeroed in the recorded packets, as +in every rsprox recording. Read what was typed from the packet that carries the text, such as +`RESUME_P_STRINGDIALOG`. + +`session_start` gives the launcher of a client 180 seconds to complete its handshake before it +abandons the launch. ## Example: check that setting a display name works -The widget ids and packet contents below are illustrative. They depend on the server. +The display-name interface is group 558. Its name field is widget `558:12`. ``` 1. session_start {"target":"My Server"} @@ -118,53 +126,43 @@ The widget ids and packet contents below are illustrative. They depend on the se 2. client_login {"username":"mcptest","password":"any"} -> {"gameState":"LOGGED_IN","cursor":2} -3. client_widgets {"text":"look up name"} - -> {"roots":[548],"widgets":[{"id":"558:7","text":"Look up name","name":"","actions":["Look up name"], - "bounds":[315,200,130,30],"click":[380,215],"type":4,"hidden":false}],"truncated":false,"cursor":604} +3. client_widgets {"group":558} + -> {"roots":[...],"widgets":[..., {"id":"558:12","text":"*","bounds":[467,350,332,22], + "click":[633,361], ...}, ...],"truncated":false,"cursor":470} -4. client_click {"widget":"558:7"} - -> {"x":380,"y":215,"cursor":640} +4. client_click {"widget":"558:12"} + -> {"x":633,"y":361,"cursor":471} -5. packets_read {"after":640,"prots":["IF_BUTTONX"],"wait_ms":3000} - -> {"next":647,"head":647,"dropped":0,"timedOut":false,"count":1} - 645 L1 T46 C IF_BUTTONX [if_buttonx] com=558:7, sub=-1, obj=-1, button=1 +5. packets_read {"after":471,"prots":["IF_BUTTONX"],"wait_ms":3000} + -> {"next":473,"head":473,"dropped":0,"timedOut":false,"count":1} + 473 L1 T5 C IF_BUTTONX [if_buttonx] com=tutorial_displayname:name (558:7), op=1 6. client_type {"text":"McpProto","enter":true} - -> {"typed":8,"cursor":655} + -> {"typed":8,"cursor":484} -7. packets_read {"after":655,"prots":["RESUME_P_STRINGDIALOG","IF_SETTEXT"],"contains":"McpProto","wait_ms":5000} - -> {"next":671,"head":671,"dropped":0,"timedOut":false,"count":2} - 662 L1 T48 C RESUME_P_STRINGDIALOG [resume_p_stringdialog] string="McpProto" - 669 L1 T49 S IF_SETTEXT [if_settext] com=558:13, text="McpProto is available" +7. packets_read {"after":484,"prots":["IF_SETTEXT"],"contains":"McpProto","wait_ms":5000} + -> {"next":519,"head":519,"dropped":0,"timedOut":false,"count":1} + 519 L1 T8 S IF_SETTEXT [if_settext] com=tutorial_displayname:status (558:13), text="Great! The display name McpProto is available!
You may set this name now, or enter another to look up." -8. client_widgets {"group":558,"text":"is available"} - -> {"roots":[548],"widgets":[{"id":"558:13","text":"McpProto is available", ...}], - "truncated":false,"cursor":671} +8. client_widgets {"group":558,"text":"available"} + -> {"roots":[...],"widgets":[{"id":"558:13","text":"Great! The display name McpProto + is available!
You may set this name now, or enter another to look up.", ...}], + "truncated":false,"cursor":520} 9. session_stop {} -> {"session":"s1","target":"My Server","state":"stopped","reason":"stopped by caller", ...} ``` -Steps 5, 7 and 8 are three separate proofs. The client sent the click. The client sent the name and -the server answered. The client rendered the answer. - -Two more patterns are useful: - -- To assert that nothing happened, wait for the packet and expect a timeout. - `packets_read {"after":655,"prots":["IF_SETTEXT"],"wait_ms":1800}` returning `"timedOut":true` means - the server sent no such packet for three ticks. -- After a server restart, call `session_start {"session":"s1"}`. The session keeps its id and its - packet log. The client comes back on new ports, and new packets are marked with the next login - number. - -## Known limits - -- The client needs a display, real or virtual. See "Run it without a display". -- The proxy only observes. It cannot inject or change packets. All input goes through the client as - mouse and key events. -- A launch whose launcher never completes its handshake is abandoned after 180 seconds. After that, - the server refuses further launches until it is restarted. -- Sessions record to the usual folder of the target, as they do when launched from the GUI. -- The rendezvous file `~/.rsprox/mcp/bridge.json` is shared, so run one MCP server per user account. -- Key codes of typed text are zeroed in the recorded packets, as in every rsprox recording. Read what - was typed from the packet that carries the text, such as `RESUME_P_STRINGDIALOG`. +Steps 5, 7 and 8 are three separate proofs. The client sent the click. The server answered the name +the client sent. The client rendered the answer. + +A wait returns as soon as one packet matches. The name itself left the client two ticks earlier, as +`RESUME_P_STRINGDIALOG [resume_p_stringdialog] string="McpProto"`, and +`packets_read {"after":484,"origin":"client"}` shows it. + +The same wait proves that nothing happened. Wait for the packet and expect a timeout: +`packets_read {"after":520,"prots":["IF_SETTEXT"],"wait_ms":1800}` returning `"timedOut":true` means +the server sent no such packet for three ticks. + +After a server restart, call `session_start {"session":"s1"}`. The session keeps its id and its packet +log. The client comes back on new ports, and new packets are marked with the next login number. diff --git a/mcp/build.gradle.kts b/mcp/build.gradle.kts index e11d0af3..e2979bc1 100644 --- a/mcp/build.gradle.kts +++ b/mcp/build.gradle.kts @@ -4,6 +4,15 @@ val bridgePlugin: Configuration by configurations.creating { isTransitive = false } +repositories { + maven { + url = uri("https://repo.runelite.net") + content { + includeGroupByRegex("net\\.runelite.*") + } + } +} + dependencies { bridgePlugin(project(":runelite:rsprox-mcp-bridge")) implementation(projects.proxy) @@ -14,6 +23,25 @@ dependencies { implementation(rootProject.libs.bundles.jackson) implementation(rootProject.libs.clikt) implementation(rootProject.libs.inline.logger) + + // The bridge tests run the real plugin against the real server, with only the game faked. + testImplementation(project(":runelite:rsprox-mcp-bridge")) + testImplementation(rootProject.libs.runelite.client) { + // Only what the plugin compiles against. The client's runtime graph adds the game and its natives. + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_API)) + } + + // The client asks for a classifier that the Guice version the proxy brings in does not publish. + exclude(group = "com.google.inject", module = "guice") + } +} + +tasks.test { + systemProperty("java.awt.headless", "true") + + // Code under test resolves ~/.rsprox from this property, so no test reads or writes the real one. + systemProperty("user.home", temporaryDir.absolutePath) } tasks.processResources { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index dff33cf2..bea7c4f8 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -10,6 +10,7 @@ import com.github.michaelbull.logging.InlineLogger import io.netty.buffer.ByteBufAllocator import net.rsprox.mcp.bridge.BridgeHub import net.rsprox.mcp.bridge.BridgeJar +import net.rsprox.mcp.bridge.Rendering import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.packets.UnfilteredFilterSetStore import net.rsprox.mcp.server.McpHttpServer @@ -61,8 +62,10 @@ public class McpCommand : CliktCommand(name = "mcp") { service.filterSetStore = UnfilteredFilterSetStore service.settingsStore = TapSettingSetStore val launcher = ProxyServiceLauncher(service, portSkip.coerceAtLeast(1), BridgeJar(sideloadDir)) - val hub = - BridgeHub(Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"), softwareRendering) + + // The plugin reads the same path in McpBridgePlugin.java. + val rendezvous = Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json") + val hub = BridgeHub(rendezvous, if (softwareRendering) Rendering.SOFTWARE else Rendering.GPU) hub.start() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index 80e13381..c4900b20 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -32,6 +32,12 @@ internal interface BridgeListener { fun onClosed(link: BridgeLink) } +/** How a launched client draws its frames. [SOFTWARE] has the plugin stop the client's GPU plugin. */ +public enum class Rendering { + GPU, + SOFTWARE, +} + /** * Where in-client bridge plugins connect. The plugin dials in: it finds the port and a token in the * rendezvous file and names the HTTP port of its launch, which routes it to the session that expects it. @@ -39,7 +45,7 @@ internal interface BridgeListener { */ public class BridgeHub( private val rendezvous: Path, - private val softwareRendering: Boolean = false, + private val rendering: Rendering = Rendering.GPU, ) : AutoCloseable { private val expected = ConcurrentHashMap() private val links = ConcurrentHashMap.newKeySet() @@ -128,13 +134,10 @@ public class BridgeHub( else -> null } - val listener = if (refusal == null) expected.remove(httpPort) else null - if (listener == null) { - writer.line(MAPPER.createObjectNode().put("reject", refusal ?: "no session expects httpPort $httpPort")) - socket.close() + if (refusal != null) return reject(socket, writer, refusal) - return - } + val listener = expected.remove(httpPort) + if (listener == null) return reject(socket, writer, "no session expects httpPort $httpPort") socket.soTimeout = 0 val link = @@ -149,7 +152,7 @@ public class BridgeHub( .createObjectNode() .put("welcome", PROTOCOL) .put("session", listener.session) - .put("softwareRendering", softwareRendering), + .put("softwareRendering", rendering == Rendering.SOFTWARE), ) if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) { @@ -163,10 +166,17 @@ public class BridgeHub( logger.info { "Bridge connected for session ${listener.session} (httpPort $httpPort)" } } + private fun reject( + socket: Socket, + writer: Writer, + reason: String, + ) { + writer.line(MAPPER.createObjectNode().put("reject", reason)) + socket.close() + } + private fun tokenMatches(candidate: JsonNode?): Boolean { - if (candidate == null || !candidate.isTextual) { - return false - } + if (candidate == null || !candidate.isTextual) return false return MessageDigest.isEqual(candidate.asText().toByteArray(), token.toByteArray()) } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt index 5fd82a76..d5172956 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt @@ -5,7 +5,7 @@ import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardCopyOption -/** Puts the bridge plugin jar that this build embeds where the client of a target sideloads plugins from. */ +/** Installs the embedded bridge plugin jar into the sideload directory of a target's client. */ public class BridgeJar internal constructor( private val overrideDir: Path?, private val home: Path, diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt index 5048cd62..57604848 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -58,7 +58,7 @@ public class BridgeLink internal constructor( public fun call( op: String, args: ObjectNode, - timeoutMs: Long = DEFAULT_TIMEOUT_MS, + timeoutMs: Long, ): JsonNode { val id = ids.incrementAndGet() val reply = CompletableFuture() @@ -139,6 +139,5 @@ public class BridgeLink internal constructor( private companion object { private val MAPPER: ObjectMapper = jacksonObjectMapper() - private const val DEFAULT_TIMEOUT_MS = 10_000L } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt index 866191d9..adc31567 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt @@ -41,8 +41,12 @@ public data class PacketQuery( val origin: Origin? = null, /** Case-insensitive substring of the record text. */ val contains: String? = null, - val limit: Int = 200, -) + val limit: Int = DEFAULT_LIMIT, +) { + public companion object { + public const val DEFAULT_LIMIT: Int = 200 + } +} public data class PacketPage( val packets: List, diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt index 180e5441..6b17d271 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -317,6 +317,10 @@ public class McpHttpServer( private val dispatcher = McpDispatcher(tools, version) private var server: HttpServer? = null + /** The port the server listens on, which differs from the requested one when that was 0. */ + internal val localPort: Int + get() = checkNotNull(server) { "the server has not been started" }.address.port + /** Throws [java.net.BindException] when the port is taken, which is how a second instance is refused. */ public fun start() { val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index afe0e18c..b2ea143c 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -29,7 +29,12 @@ public fun tools(sessions: () -> SessionManager): List = schema( "target" to string("Proxy target name, as listed by session_list. Example: \"My Server\"."), "session" to string("Existing session id, such as \"s1\"."), - "wait_ms" to integer("How long to wait for the session to connect. Default 180000.", 0, 600_000), + "wait_ms" to + integer( + "How long to wait for the session to connect. Default ${SessionManager.DEFAULT_WAIT_MS}.", + 0, + 600_000, + ), ), ) { args -> ToolResult.Json( @@ -59,7 +64,7 @@ public fun tools(sessions: () -> SessionManager): List = name = "packets_read", description = "Read decoded packets of a session after a cursor, unfiltered, in both directions. " + - "With `wait_ms` it blocks until the first match, which makes it the way to wait for a packet. " + + "To wait for a packet, pass `wait_ms`. The call then blocks until the first match. " + "Line 1 of the result is JSON: pass `next` as `after` on the following call; `dropped` counts " + "records that were evicted before they could be read; `timedOut` is true when a wait " + "elapsed with no match. Each further line is one packet: " + @@ -74,7 +79,8 @@ public fun tools(sessions: () -> SessionManager): List = "origin" to string("Keep only one origin.", "client", "server", "proxy"), "contains" to string("Case-insensitive substring the packet text must contain."), "wait_ms" to integer("How long to block for a first match. Default 0.", 0, 120_000), - "limit" to integer("Maximum number of packets to return. Default 200.", 1, 1000), + "limit" to + integer("Maximum number of packets to return. Default ${PacketQuery.DEFAULT_LIMIT}.", 1, 1000), ), ) { args -> val query = @@ -83,7 +89,7 @@ public fun tools(sessions: () -> SessionManager): List = prots = args.get("prots")?.map { it.asText().uppercase() }?.toSet().orEmpty(), origin = args.text("origin")?.let { Origin.valueOf(it.uppercase()) }, contains = args.text("contains"), - limit = args.long("limit")?.toInt() ?: 200, + limit = args.long("limit")?.toInt() ?: PacketQuery.DEFAULT_LIMIT, ) val page = sessions().resolve(args.text("session")).packets.read(query, args.long("wait_ms") ?: 0) @@ -122,9 +128,10 @@ public fun tools(sessions: () -> SessionManager): List = "group" to integer("Keep only widgets of this interface group, such as 558.", 0), "text" to string("Case-insensitive substring to find in the text, name or actions."), "hidden" to boolean("Also list hidden widgets. Default false."), - "limit" to integer("Maximum number of widgets to return. Default 200.", 1, 2000), + "limit" to integer("Maximum number of widgets to return. Default $WIDGET_LIMIT.", 1, 2000), ), sessions = sessions, + defaults = mapOf("limit" to WIDGET_LIMIT), ), clientTool( name = "client_vars", @@ -153,12 +160,14 @@ public fun tools(sessions: () -> SessionManager): List = schema( "session" to SESSION, "username" to string("Account name to log in with."), - "password" to string("Password. An empty one is refused, so pass any value to a server that ignores it."), - "wait_ms" to integer("How long to wait for the login to complete. Default 15000.", 0, 120_000), + "password" to string("Password. It must not be empty. A server that ignores passwords accepts any value."), + "wait_ms" to + integer("How long to wait for the login to complete. Default $LOGIN_WAIT_MS.", 0, 120_000), required = listOf("username", "password"), ), sessions = sessions, - timeoutMs = { args -> (args.long("wait_ms") ?: LOGIN_WAIT_MS) + CLIENT_CALL_TIMEOUT_MS }, + defaults = mapOf("wait_ms" to LOGIN_WAIT_MS), + timeoutMs = { forwarded -> forwarded.get("wait_ms").asLong() + CLIENT_CALL_TIMEOUT_MS }, ), clientTool( name = "client_click", @@ -195,12 +204,15 @@ public fun tools(sessions: () -> SessionManager): List = /** * The shape every client_* tool shares. The plugin's answer is the tool result, plus the packet cursor * taken before the call, so everything the action caused has a sequence number above it. + * + * The plugin has no defaults of its own: each of [defaults] is forwarded when the caller left it out. */ private fun clientTool( name: String, description: String, schema: ObjectNode, sessions: () -> SessionManager, + defaults: Map = emptyMap(), timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, result: (ObjectNode) -> ToolResult = ToolResult::Json, ): Tool = @@ -208,17 +220,23 @@ private fun clientTool( val session = sessions().resolve(args.text("session")) val cursor = session.packets.head().seq val forwarded = args.deepCopy().without("session") - val ok = session.requireLink().call(name.removePrefix("client_"), forwarded, timeoutMs(args)) + for ((argument, value) in defaults) { + if (!forwarded.hasNonNull(argument)) forwarded.put(argument, value) + } + + val ok = session.requireLink().call(name.removePrefix("client_"), forwarded, timeoutMs(forwarded)) if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") + result(ok.put("cursor", cursor)) } private const val CLIENT_CALL_TIMEOUT_MS = 10_000L -// Mirrors the default the plugin applies when `wait_ms` is absent. private const val LOGIN_WAIT_MS = 15_000L +private const val WIDGET_LIMIT = 200L + private const val CURSOR_NOTE = "The result carries `cursor`, the packet cursor taken just before the call: pass it as `after` to " + "packets_read to see only the packets from this call onwards." diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index 016bfc6a..c94c1293 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -28,12 +28,7 @@ internal class ProxyServiceLauncher( bridgeJar.installFor(target) // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. - var port: Int - - do { - port = service.allocatePort() - } while (!isFree(port) || !isFree(HTTP_PORT_BASE + (port - basePort))) - + val port = firstFreePort(service::allocatePort, { HTTP_PORT_BASE + (it - basePort) }, ::canBind) val proxyTarget = service.initializeHttpServer(port, target) return Reservation(port, proxyTarget.httpPort) { monitor -> @@ -41,7 +36,7 @@ internal class ProxyServiceLauncher( // Probing leaves a window in which another process can take the port. The proxy registers // a client type for a port only after it has bound it. - check(isBound(port)) { "proxy port $port could not be bound" } + check(hasClientType(port)) { "proxy port $port could not be bound" } } } @@ -49,7 +44,7 @@ internal class ProxyServiceLauncher( service.killAliveProcess(proxyPort) } - private fun isBound(port: Int): Boolean = + private fun hasClientType(port: Int): Boolean = try { ClientTypeDictionary[port] true @@ -57,16 +52,23 @@ internal class ProxyServiceLauncher( false } - private fun isFree(port: Int): Boolean = - try { - ServerSocket(port).close() - true - } catch (e: IOException) { - false - } - private companion object { // Mirrors net.rsprox.proxy.config.HTTP_SERVER_PORT, which is internal to the proxy module. private const val HTTP_PORT_BASE = 43600 } } + +/** The first port from [allocate] that is free together with the HTTP port that belongs to it. */ +internal fun firstFreePort( + allocate: () -> Int, + httpPortOf: (Int) -> Int, + isFree: (Int) -> Boolean, +): Int = generateSequence(allocate).first { isFree(it) && isFree(httpPortOf(it)) } + +internal fun canBind(port: Int): Boolean = + try { + ServerSocket(port).close() + true + } catch (e: IOException) { + false + } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index 51cf020a..a733ac43 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -102,14 +102,9 @@ public class SessionManager internal constructor( ?: throw ToolError("no session '$ref'. Sessions: ${sessionIds()}") } - return sessions.singleOrNull() - ?: throw ToolError( - if (sessions.isEmpty()) { - "no session exists yet; call session_start first" - } else { - "several sessions exist; pass one of: ${sessionIds()}" - }, - ) + if (sessions.isEmpty()) throw ToolError("no session exists yet; call session_start first") + + return sessions.singleOrNull() ?: throw ToolError("several sessions exist; pass one of: ${sessionIds()}") } public fun targets(): List = launcher.targets().map { it.name } @@ -163,13 +158,12 @@ public class SessionManager internal constructor( thread.isDaemon = true thread.start() thread.join(launchTimeoutMs) - val reason = - if (thread.isAlive) { - hungLaunch = true - "launcher never completed its handshake" - } else { - failure.get()?.let(::rootMessage) ?: return - } + + val hung = thread.isAlive + val reason = if (hung) "launcher never completed its handshake" else failure.get()?.let(::rootMessage) + if (reason == null) return + + if (hung) hungLaunch = true launcher.kill(launch.proxyPort) session.apply(SessionEvent.Stop(reason)) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index f422a494..8d552a5f 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -93,7 +93,7 @@ class BridgeHubTest { @Test fun `a hub set to software rendering tells each client it welcomes`() { - TestHub(softwareRendering = true).use { other -> + TestHub(Rendering.SOFTWARE).use { other -> other.hub.expect(43650, RecordingListener()) val plugin = FakePlugin.dial(other.rendezvous, 43650, null, 1).also { plugins += it } @@ -150,7 +150,7 @@ class BridgeHubTest { @Test fun `a call sends the op and its args and returns the ok value`() { val (plugin, link) = connect() - val call = async { link.call("click", args("""{"x":380,"y":215}""")) } + val call = async { link.call("click", args("""{"x":380,"y":215}"""), TIMEOUT_MS) } val request = plugin.read()!! assertEquals("click", request.get("op").asText()) @@ -163,9 +163,9 @@ class BridgeHubTest { @Test fun `overlapping calls each receive their own reply, whatever the order of the answers`() { val (plugin, link) = connect() - val slow = async { link.call("screenshot", args()) } + val slow = async { link.call("screenshot", args(), TIMEOUT_MS) } val first = plugin.read()!! - val fast = async { link.call("state", args()) } + val fast = async { link.call("state", args(), TIMEOUT_MS) } val second = plugin.read()!! assertEquals(listOf("screenshot", "state"), listOf(first.get("op").asText(), second.get("op").asText())) @@ -182,7 +182,7 @@ class BridgeHubTest { val (plugin, link) = connect() plugin.serve { _, _ -> """"err":{"code":"not_found","message":"widget 558:7 is not visible"}""" } - val error = assertFailsWith { link.call("click", args("""{"widget":"558:7"}""")) } + val error = assertFailsWith { link.call("click", args("""{"widget":"558:7"}"""), TIMEOUT_MS) } assertEquals("not_found", error.code) assertEquals("widget 558:7 is not visible", error.message) } @@ -197,21 +197,21 @@ class BridgeHubTest { val late = plugin.read()!! plugin.send("""{"id":${late.get("id")},"ok":{}}""") plugin.serve { _, _ -> """"ok":{"tick":7}""" } - assertEquals(7, link.call("state", args()).get("tick").asInt()) + assertEquals(7, link.call("state", args(), TIMEOUT_MS).get("tick").asInt()) } @Test fun `a closed connection fails the pending calls, reports the close and refuses new calls`() { val listener = RecordingListener() val (plugin, link) = connect(listener) - val pending = async { link.call("screenshot", args()) } + val pending = async { link.call("screenshot", args(), TIMEOUT_MS) } plugin.read() plugin.close() assertEquals("closed", bridgeError(pending).code) assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) - assertEquals("closed", assertFailsWith { link.call("state", args()) }.code) + assertEquals("closed", assertFailsWith { link.call("state", args(), TIMEOUT_MS) }.code) } @Test @@ -224,4 +224,8 @@ class BridgeHubTest { assertNull(plugin.read()) assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) } + + private companion object { + private const val TIMEOUT_MS = 10_000L + } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index f735ac9a..25ed3848 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -34,10 +34,10 @@ internal fun awaitTrue( /** A started hub whose rendezvous file lives in a temporary directory. */ internal class TestHub( - softwareRendering: Boolean = false, + rendering: Rendering = Rendering.GPU, ) : AutoCloseable { val rendezvous: Path = Files.createTempDirectory("mcp-bridge-test").resolve("mcp").resolve("bridge.json") - val hub = BridgeHub(rendezvous, softwareRendering).also { it.start() } + val hub = BridgeHub(rendezvous, rendering).also { it.start() } override fun close() { hub.close() diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt new file mode 100644 index 00000000..4213ed64 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt @@ -0,0 +1,230 @@ +package net.rsprox.mcp.packets + +import net.rsprox.cache.api.Cache +import net.rsprox.cache.api.type.GameVal +import net.rsprox.cache.api.type.GameValType +import net.rsprox.cache.api.type.NpcType +import net.rsprox.cache.api.type.VarBitType +import net.rsprox.mcp.session.LoginInfo +import net.rsprox.mcp.session.Session +import net.rsprox.mcp.session.SessionId +import net.rsprox.mcp.session.target +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection.CLIENT_TO_SERVER +import net.rsprox.shared.StreamDirection.SERVER_TO_CLIENT +import net.rsprox.shared.property.ChildProperty +import net.rsprox.shared.property.RootProperty +import net.rsprox.shared.property.com +import net.rsprox.shared.property.group +import net.rsprox.shared.property.int +import net.rsprox.shared.property.string +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Drives the session monitor of one launch the way the proxy does and reads what the session shows. */ +class PacketTapTest { + private val session = Session(SessionId("s1"), target(1, "My Server")) + private val tap = PacketTap(session.packets, session.logins, TapSettingSetStore) + + private fun header(world: Int): BinaryHeader = + BinaryHeader( + headerVersion = 1, + revision = 241, + subRevision = 1, + clientType = 1, + platformType = 1, + timestamp = 0, + worldId = world, + worldFlags = 0, + worldLocation = 0, + worldHost = "127.0.0.1", + worldActivity = "", + localPlayerIndex = 1, + accountHash = ByteArray(0), + clientName = "RuneLite", + js5MasterIndex = ByteArray(0), + ) + + /** A login as the proxy reports it: a monitor bound to the recording, then the login itself. */ + private fun login(world: Int): SessionMonitor { + val header = header(world) + + return tap.forSession(header).also { it.onLogin(header) } + } + + /** A cache with no names in it, which is enough to format ids as numbers. */ + private fun SessionMonitor.withCache(): SessionMonitor { + val cache = + object : Cache { + override fun getNpcType(id: Int): NpcType? = null + + override fun listNpcTypes(): Collection = emptyList() + + override fun getVarBitType(id: Int): VarBitType? = null + + override fun listVarBitTypes(): Collection = emptyList() + + override fun getGameValType( + gameVal: GameVal, + id: Int, + ): GameValType? = null + + override fun listGameValTypes(gameVal: GameVal): Collection = emptyList() + + override fun allGameValTypes(): Map> = emptyMap() + } + + onCacheUpdate { cache } + + return this + } + + private fun packet( + prot: String, + build: RootProperty.() -> Unit = {}, + ): RootProperty { + val root = + object : RootProperty { + override val prot: String = prot + override val children: MutableList> = mutableListOf() + } + + return root.apply(build) + } + + private fun records(): List = + session.packets.read(PacketQuery()).packets.map { + "L${it.login} T${it.cycle} ${it.origin.letter} ${it.prot} ${it.text}" + } + + @Test + fun `a login is marked in the log and shows in the snapshot`() { + login(world = 302) + + assertEquals(listOf("L1 T0 P LOGIN revision=241 world=302"), records()) + assertEquals( + LoginInfo(epoch = 1, revision = 241, world = 302, name = null, online = true, transcribing = false), + session.snapshot().login, + ) + } + + @Test + fun `each packet takes its origin from the last direction the proxy announced`() { + val monitor = login(world = 302).withCache() + + monitor.onPacketDirection(CLIENT_TO_SERVER) + monitor.onTranscribe(48, packet("if_button") { com(558, 7) }) + monitor.onPacketDirection(SERVER_TO_CLIENT) + monitor.onTranscribe(49, packet("if_settext") { string("text", "McpProto is available") }) + monitor.onTranscribe(49, packet("server_tick_end")) + monitor.onPacketDirection(CLIENT_TO_SERVER) + monitor.onTranscribe(50, packet("resume_p_stringdialog") { string("string", "McpProto") }) + + assertEquals( + listOf( + "L1 T48 C IF_BUTTON [if_button] com=558:7", + "L1 T49 S IF_SETTEXT [if_settext] text=\"McpProto is available\"", + "L1 T49 S SERVER_TICK_END [server_tick_end] ", + "L1 T50 C RESUME_P_STRINGDIALOG [resume_p_stringdialog] string=\"McpProto\"", + ), + records().drop(1), + ) + } + + @Test + fun `a packet with nested properties is recorded as several lines`() { + val monitor = login(world = 302).withCache() + + monitor.onPacketDirection(SERVER_TO_CLIENT) + monitor.onTranscribe( + 7, + packet("player_info") { + group("localplayer") { + int("index", 1) + } + }, + ) + + assertEquals("L1 T7 S PLAYER_INFO [player_info] \n [localplayer] index=1", records().last()) + } + + @Test + fun `the login counts as transcribing from its first packet on`() { + val monitor = login(world = 302) + assertEquals(false, session.snapshot().login?.transcribing) + + monitor.onPacketDirection(SERVER_TO_CLIENT) + + assertEquals(true, session.snapshot().login?.transcribing) + } + + @Test + fun `a second login gets the next epoch and the first can no longer change what the session shows`() { + val first = login(world = 302) + first.onNameUpdate("First") + first.onPacketDirection(SERVER_TO_CLIENT) + + val second = login(world = 303) + second.onPacketDirection(CLIENT_TO_SERVER) + second.onTranscribe(3, packet("no_timeout")) + first.onTranscribe(90, packet("server_tick_end")) + first.onLogout(header(302)) + first.onNameUpdate("Late") + + assertEquals( + listOf( + "L1 T0 P LOGIN revision=241 world=302", + "L2 T0 P LOGIN revision=241 world=303", + "L2 T3 C NO_TIMEOUT [no_timeout] ", + "L1 T90 S SERVER_TICK_END [server_tick_end] ", + "L1 T0 P LOGOUT world=302", + ), + records(), + ) + assertEquals( + LoginInfo(epoch = 2, revision = 241, world = 303, name = null, online = true, transcribing = true), + session.snapshot().login, + ) + } + + @Test + fun `a name update and a logout show in the snapshot`() { + val monitor = login(world = 302) + + monitor.onNameUpdate("McpProto") + assertEquals("McpProto", session.snapshot().login?.name) + + monitor.onLogout(header(302)) + + assertEquals(false, session.snapshot().login?.online) + assertEquals("L1 T0 P LOGOUT world=302", records().last()) + } + + @Test + fun `a packet that cannot be formatted is recorded as such and the packets after it still arrive`() { + val monitor = login(world = 302) + + monitor.onPacketDirection(CLIENT_TO_SERVER) + monitor.onTranscribe(48, packet("if_button") { com(558, 7) }) + monitor.onTranscribe(49, packet("no_timeout")) + + assertEquals( + listOf( + "L1 T48 C IF_BUTTON [if_button] ", + "L1 T49 C NO_TIMEOUT [no_timeout] ", + ), + records().drop(1), + ) + } + + @Test + fun `the monitor of a launch records nothing before a login binds it`() { + tap.onPacketDirection(SERVER_TO_CLIENT) + tap.onTranscribe(1, packet("server_tick_end")) + tap.onNameUpdate("McpProto") + + assertEquals(emptyList(), records()) + assertEquals(null, session.snapshot().login) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt new file mode 100644 index 00000000..4a8a719a --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt @@ -0,0 +1,132 @@ +package net.rsprox.mcp.server + +import java.net.BindException +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class McpHttpServerTest { + private val echo = + Tool( + name = "echo", + description = "Returns its text argument", + inputSchema = + McpDispatcher.MAPPER + .createObjectNode() + .put("type", "object") + .also { it.putObject("properties").putObject("text").put("type", "string") }, + ) { args -> ToolResult.Text(args.get("text").asText()) } + + private val server = McpHttpServer(0, listOf(echo), "1.2.3").also { it.start() } + private val client = HttpClient.newHttpClient() + + @AfterTest + fun cleanUp() { + server.close() + } + + private fun request(path: String = "/mcp"): HttpRequest.Builder = + HttpRequest.newBuilder(URI("http://127.0.0.1:${server.localPort}$path")) + + private fun send(request: HttpRequest.Builder): HttpResponse = + client.send(request.build(), HttpResponse.BodyHandlers.ofString()) + + private fun post( + body: String, + origin: String? = null, + ): HttpResponse { + val request = request().POST(HttpRequest.BodyPublishers.ofString(body)) + + if (origin != null) request.header("Origin", origin) + + return send(request) + } + + @Test + fun `a tool call is answered as json over http`() { + val response = + post( + """{"jsonrpc":"2.0","id":7,"method":"tools/call",""" + + """"params":{"name":"echo","arguments":{"text":"héllo"}}}""", + ) + + assertEquals(200, response.statusCode()) + assertEquals("application/json", response.headers().firstValue("Content-Type").orElse(null)) + assertEquals( + """{"jsonrpc":"2.0","id":7,"result":{"isError":false,"content":[{"type":"text","text":"héllo"}]}}""", + response.body(), + ) + } + + @Test + fun `a request from a page on another site is forbidden and one from a local page is served`() { + val ping = """{"jsonrpc":"2.0","id":1,"method":"ping"}""" + + val foreign = post(ping, origin = "https://example.com") + assertEquals(403, foreign.statusCode()) + assertEquals("", foreign.body()) + + val local = post(ping, origin = "http://localhost:6274") + assertEquals(200, local.statusCode()) + assertEquals("""{"jsonrpc":"2.0","id":1,"result":{}}""", local.body()) + } + + @Test + fun `a notification is accepted with no body`() { + val response = post("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + + assertEquals(202, response.statusCode()) + assertEquals("", response.body()) + } + + @Test + fun `a GET is refused and told that POST is allowed`() { + val response = send(request().GET()) + + assertEquals(405, response.statusCode()) + assertEquals("POST", response.headers().firstValue("Allow").orElse(null)) + assertEquals("", response.body()) + } + + @Test + fun `a body that is not json is a parse error`() { + val response = post("{not json") + + assertEquals(400, response.statusCode()) + assertEquals( + """{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}""", + response.body(), + ) + } + + @Test + fun `a batch is an invalid request`() { + val response = post("""[{"jsonrpc":"2.0","id":1,"method":"ping"}]""") + + assertEquals(400, response.statusCode()) + assertEquals( + """{"jsonrpc":"2.0","id":null,"error":{"code":-32600,""" + + """"message":"Expected one JSON-RPC object; batching is unsupported"}}""", + response.body(), + ) + } + + @Test + fun `a path below the endpoint is not found`() { + val response = send(request("/mcp/other").POST(HttpRequest.BodyPublishers.ofString("{}"))) + + assertEquals(404, response.statusCode()) + } + + @Test + fun `a second server on the same port is refused`() { + val second = McpHttpServer(server.localPort, listOf(echo), "1.2.3") + + assertFailsWith { second.start() } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt new file mode 100644 index 00000000..e577d2bd --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt @@ -0,0 +1,23 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode + +/** The smallest arguments [tool] accepts: a value of the right type for each required property. */ +internal fun minimalArguments(tool: Tool): ObjectNode { + val arguments = McpDispatcher.MAPPER.createObjectNode() + + for (required in tool.inputSchema.get("required") ?: emptyList()) { + val property = tool.inputSchema.get("properties").get(required.asText()) + + when (val type = property.get("type").asText()) { + "string" -> arguments.put(required.asText(), property.get("enum")?.get(0)?.asText() ?: "x") + "integer" -> arguments.put(required.asText(), property.get("minimum")?.asLong() ?: 0) + "boolean" -> arguments.put(required.asText(), false) + "array" -> arguments.putArray(required.asText()) + else -> error("${tool.name} requires '${required.asText()}' of type $type, which this helper cannot make") + } + } + + return arguments +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt index bcfc9492..d9324433 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt @@ -7,6 +7,7 @@ import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.session.FakeLauncher import net.rsprox.mcp.session.SessionManager +import java.util.concurrent.CopyOnWriteArrayList import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -56,25 +57,50 @@ class ToolsTest { } @Test - fun `the tools are listed`() { + fun `every tool is listed with a description and an object schema`() { val body = dispatcher.handle("POST", null, """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""").body - val names = mapper.readTree(body).get("result").get("tools").map { it.get("name").asText() } - assertEquals( - listOf( - "session_start", - "session_stop", - "session_list", - "packets_read", - "client_state", - "client_screenshot", - "client_widgets", - "client_vars", - "client_login", - "client_click", - "client_type", - ), - names, - ) + val listed = mapper.readTree(body).get("result").get("tools") + assertTrue(listed.size() > 0) + + for (tool in listed) { + val name = tool.get("name").asText() + assertTrue(tool.get("description").asText().isNotBlank(), name) + assertEquals("object", tool.get("inputSchema").get("type").asText(), name) + assertTrue(tool.get("inputSchema").get("properties").isObject, name) + } + } + + @Test + fun `every client tool calls the op its name ends in, strips the session and adds the cursor from before`() { + val forwarded = CopyOnWriteArrayList>() + connect { op, args -> + forwarded += op to args + + // What the action causes arrives while the call is in flight. + manager.resolve("s1").packets.append(1, 5, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"hello\"") + """"ok":{}""" + } + + val log = manager.resolve("s1").packets + val clientTools = tools { manager }.filter { it.name.startsWith("client_") } + assertTrue(clientTools.isNotEmpty()) + + for (tool in clientTools) { + forwarded.clear() + val before = log.head().seq + val arguments = minimalArguments(tool) + + val result = call(tool.name, arguments.deepCopy().put("session", "s1").toString()) + + assertFalse(result.get("isError").asBoolean(), "${tool.name}: $result") + val (op, sent) = forwarded.single() + assertEquals(tool.name.removePrefix("client_"), op) + assertFalse(sent.has("session"), tool.name) + arguments.fields().forEach { (name, value) -> assertEquals(value, sent.get(name), "${tool.name} $name") } + val answer = mapper.readTree(result.get("content").last().get("text").asText()) + assertEquals(before, answer.get("cursor").asLong(), tool.name) + assertEquals(before + 1, log.head().seq, tool.name) + } } @Test @@ -187,6 +213,19 @@ class ToolsTest { ) } + @Test + fun `a widget listing without a limit forwards the default one`() { + val forwarded = ArrayList() + connect { _, args -> + forwarded.add(args) + """"ok":{"roots":[548],"widgets":[],"truncated":false}""" + } + + text("client_widgets", """{"group":558}""") + + assertEquals(listOf(mapper.readTree("""{"group":558,"limit":200}""")), forwarded) + } + @Test fun `a screenshot is an image block followed by its size and the cursor`() { connect { _, _ -> """"ok":{"png":"iVBORw0KGgo=","width":976,"height":558}""" } @@ -300,7 +339,7 @@ class ToolsTest { ) text("client_login", """{"username":"mcp","password":"secret"}""") - assertEquals(listOf(mapper.readTree("""{"username":"mcp","password":"secret"}""")), calls) + assertEquals(listOf(mapper.readTree("""{"username":"mcp","password":"secret","wait_ms":15000}""")), calls) } @Test diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt new file mode 100644 index 00000000..2c8784b4 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt @@ -0,0 +1,30 @@ +package net.rsprox.mcp.session + +import java.net.ServerSocket +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProxyServiceLauncherTest { + @Test + fun `a proxy port must never be handed out while it or its http port is taken`() { + val allocated = generateSequence(100) { it + 1 }.iterator() + val taken = setOf(100, 1101) + + val port = firstFreePort(allocated::next, { it + 1000 }, { it !in taken }) + + assertEquals(102, port) + assertEquals(103, allocated.next()) + } + + @Test + fun `a port that another socket holds cannot be bound and a released one can`() { + val holder = ServerSocket(0) + val port = holder.localPort + + holder.use { assertFalse(canBind(port)) } + + assertTrue(canBind(port)) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt index 733cc8dc..d6c1c045 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt @@ -259,6 +259,31 @@ class SessionManagerTest { assertEquals(2, relaunched.generation) } + @Test + fun `a client that says hello after its session was stopped is dropped`() { + manager.start(null, null, 0) + manager.stop(null) + + val late = dial(43650) + + assertEquals("s1", late.read()?.get("session")?.asText()) + assertNull(late.read()) + assertEquals("stopped by caller", manager.list().single().reason) + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), markers()) + } + + @Test + fun `starting a connected session again launches nothing and keeps its client`() { + connect() + + val again = manager.start(null, "s1", 0) + + assertEquals("connected", again.state) + assertEquals(1, again.generation) + assertEquals(1, launcher.reserved.size) + assertEquals(emptyList(), launcher.killed) + } + @Test fun `an omitted session resolves only when exactly one exists`() { val none = assertFailsWith { manager.resolve(null) } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt index 88152cf2..4bfc548a 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt @@ -5,7 +5,6 @@ import net.rsprox.mcp.packets.PacketQuery import net.rsprox.mcp.server.ToolError import net.rsprox.mcp.session.ClientState.Connected import net.rsprox.mcp.session.ClientState.Launching -import net.rsprox.mcp.session.ClientState.Stopped import net.rsprox.mcp.session.SessionEvent.Hello import net.rsprox.mcp.session.SessionEvent.Launched import net.rsprox.mcp.session.SessionEvent.LinkClosed @@ -42,48 +41,10 @@ class SessionTest { private val link = idleLink() @Test - fun `a launch starts only from stopped`() { - assertEquals(Launching(first), reduce(Stopped("not started"), Launched(first))) - assertEquals(Launching(first), reduce(Launching(first), Launched(second))) - val connected = Connected(first, link, pid = 7) - assertEquals(connected, reduce(connected, Launched(second))) - } - - @Test - fun `a hello for the current launch connects it`() { - assertEquals( - Connected(first, link, pid = 7), - reduce(Launching(first), Hello(first.httpPort, link, pid = 7)), - ) - } - - @Test - fun `a hello from an earlier launch is dropped`() { - assertEquals(Launching(second), reduce(Launching(second), Hello(first.httpPort, link, pid = 7))) - assertEquals(Stopped("stopped by caller"), reduce(Stopped("stopped by caller"), Hello(first.httpPort, link, 7))) - val connected = Connected(first, link, pid = 7) - assertEquals(connected, reduce(connected, Hello(first.httpPort, idleLink(), pid = 8))) - } - - @Test - fun `closing the current link stops the session`() { - assertEquals(Stopped("client exited"), reduce(Connected(first, link, pid = 7), LinkClosed(link))) - } - - @Test - fun `a close from an earlier link is dropped`() { + fun `a close from the link of an earlier launch must never stop the relaunched session`() { val relaunched = Connected(second, idleLink(), pid = 9) - assertEquals(relaunched, reduce(relaunched, LinkClosed(link))) - assertEquals(Launching(second), reduce(Launching(second), LinkClosed(link))) - assertEquals(Stopped("stopped by caller"), reduce(Stopped("stopped by caller"), LinkClosed(link))) - } - @Test - fun `stop applies from every running state and keeps the first reason when repeated`() { - assertEquals(Stopped("stopped by caller"), reduce(Launching(first), Stop("stopped by caller"))) - assertEquals(Stopped("stopped by caller"), reduce(Connected(first, link, 7), Stop("stopped by caller"))) - val once = reduce(Launching(first), Stop("stopped by caller")) - assertEquals(once, reduce(once, Stop("again"))) + assertSame(relaunched, reduce(relaunched, LinkClosed(link))) } @Test diff --git a/mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt b/mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt new file mode 100644 index 00000000..0bb50a13 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt @@ -0,0 +1,522 @@ +package net.rsprox.mcpbridge + +import com.fasterxml.jackson.databind.JsonNode +import com.google.gson.Gson +import net.rsprox.mcp.bridge.BridgeError +import net.rsprox.mcp.bridge.Rendering as ServerRendering +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.bridge.awaitTrue +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.server.McpDispatcher +import net.rsprox.mcp.server.minimalArguments +import net.rsprox.mcp.server.tools +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.SessionManager +import net.runelite.api.GameState +import net.runelite.api.MenuAction +import java.awt.Color +import java.awt.Rectangle +import java.awt.event.KeyEvent +import java.awt.image.BufferedImage +import java.nio.file.Path +import java.util.Base64 +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import javax.imageio.ImageIO +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The server and the plugin together: every tool call crosses the real hub, a loopback socket and the + * real plugin, and ends in a stand-in for the game. + */ +class BridgeEndToEndTest { + private val mapper = McpDispatcher.MAPPER + private val fixture = TestHub() + private val launcher = FakeLauncher() + private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub) + private val dispatcher = McpDispatcher(tools { manager }, "test") + private val game = FakeGame() + private val connections = ArrayList() + + @AfterTest + fun cleanUp() { + connections.forEach { it.close() } + fixture.close() + game.close() + } + + /** Starts the plugin as a launched client does. Null when the plugin stayed dormant. */ + private fun startPlugin( + rendezvous: Path, + httpPort: Int, + ): BridgeConnection? { + val ops = Ops(GameAccess(game.client, game.clientThread, game.frames)) + + return BridgeConnection.dial(rendezvous, httpPort, Gson(), ops)?.also { connections += it } + } + + /** Session s1, launched and connected to the plugin. */ + private fun connect(): BridgeConnection { + launcher.launch = { startPlugin(fixture.rendezvous, 43650) } + assertEquals("connected", manager.start(null, null, 10_000).state) + + return connections.single() + } + + private fun call( + name: String, + arguments: String = "{}", + ): JsonNode { + val body = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$name","arguments":$arguments}}""" + + return mapper.readTree(dispatcher.handle("POST", null, body).body).get("result") + } + + private fun text( + name: String, + arguments: String = "{}", + ): String { + val result = call(name, arguments) + assertFalse(result.get("isError").asBoolean(), result.toString()) + + return result.get("content")[0].get("text").asText() + } + + private fun failure( + name: String, + arguments: String = "{}", + ): String { + val result = call(name, arguments) + assertTrue(result.get("isError").asBoolean(), result.toString()) + + return result.get("content")[0].get("text").asText() + } + + private fun cursor(): Long = manager.resolve("s1").packets.head().seq + + @Test + fun `a launched client says hello and its session connects with the client's pid`() { + launcher.launch = { startPlugin(fixture.rendezvous, 43650) } + + val started = mapper.readTree(text("session_start", """{"wait_ms":10000}""")) + + assertEquals("connected", started.get("state").asText()) + assertEquals(ProcessHandle.current().pid(), started.get("pid").asLong()) + assertEquals(Rendering.GPU, connections.single().rendering()) + assertEquals( + listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED"), + manager.resolve("s1").packets.read(PacketQuery()).packets.map { it.prot }, + ) + } + + @Test + fun `a server set to software rendering tells the plugin it welcomes`() { + TestHub(ServerRendering.SOFTWARE).use { software -> + val softwareManager = SessionManager(FakeLauncher(), TapSettingSetStore, software.hub) + softwareManager.start(null, null, 0) + + val connection = startPlugin(software.rendezvous, 43650) + + assertEquals(Rendering.SOFTWARE, connection?.rendering()) + assertEquals("connected", softwareManager.start(null, "s1", 10_000).state) + } + } + + @Test + fun `a client that no session expects stays dormant`() { + manager.start(null, null, 0) + + assertNull(startPlugin(fixture.rendezvous, 5)) + + assertEquals("launching", manager.list().single().state) + } + + @Test + fun `a client that rsprox did not launch stays dormant`() { + manager.start(null, null, 0) + + assertNull(startPlugin(fixture.rendezvous, -1)) + + assertEquals("launching", manager.list().single().state) + } + + @Test + fun `a client stays dormant while no server has left a rendezvous file`() { + assertNull(startPlugin(fixture.rendezvous.resolveSibling("absent.json"), 43650)) + } + + @Test + fun `a plugin that shuts down stops its session`() { + val connection = connect() + + connection.close() + + awaitTrue("the session is stopped") { manager.list().single().state == "stopped" } + assertEquals("client exited", manager.list().single().reason) + } + + @Test + fun `state on the login screen has no player`() { + connect() + + assertEquals( + """{"gameState":"LOGIN_SCREEN","tick":0,"canvas":[765,503],"world":301,"player":null,""" + + """"menu":{"open":false,"entries":[{"option":"Cancel","target":"","type":"CANCEL","id":0,""" + + """"p0":0,"p1":0}]},"cursor":${cursor()}}""", + text("client_state"), + ) + } + + @Test + fun `state in the game describes the player and the open menu`() { + connect() + game.logIn("McpProto") + game.menuOpen = true + game.menu = + listOf( + menuEntry("Cancel", "", MenuAction.CANCEL, 0, 0, 0), + menuEntry("Talk-to", "Guide", MenuAction.NPC_FIRST_OPTION, 3308, 52, 49), + ) + + val state = mapper.readTree(text("client_state")) + + assertEquals("LOGGED_IN", state.get("gameState").asText()) + assertEquals(mapper.readTree("""{"name":"McpProto","x":3222,"y":3218,"plane":0}"""), state.get("player")) + assertTrue(state.get("menu").get("open").asBoolean()) + assertEquals( + mapper.readTree( + """{"option":"Talk-to","target":"Guide","type":"NPC_FIRST_OPTION","id":3308, + "p0":52,"p1":49}""", + ), + state.get("menu").get("entries")[1], + ) + } + + /** Interface 558 under root 548: a button, a name field that is a dynamic child, and a hidden label. */ + private fun showDisplayNameInterface() { + val button = widget(558, 7, text = "Look up name", actions = listOf("Select", null, "")) + val field = widget(558, 12, index = 3, name = "Name field", bounds = Rectangle(467, 350, 332, 22)) + val layer = widget(558, 12, dynamic = listOf(field)) + val status = widget(558, 13, text = "Enter a name", hidden = true) + val container = widget(558, 0, static = listOf(button, layer, status)) + + game.roots = listOf(widget(548, 0, text = "Root", nested = listOf(container))) + } + + @Test + fun `widgets lists what can be read or clicked, with its bounds and click point`() { + connect() + showDisplayNameInterface() + + assertEquals( + """{"roots":[548],"widgets":[""" + + """{"id":"548:0","text":"Root","name":"","actions":[],"bounds":[0,0,10,10],"click":[5,5],""" + + """"type":4,"hidden":false},""" + + """{"id":"558:7","text":"Look up name","name":"","actions":["Select"],"bounds":[0,0,10,10],""" + + """"click":[5,5],"type":4,"hidden":false},""" + + """{"id":"558:12[3]","text":"","name":"Name field","actions":[],"bounds":[467,350,332,22],""" + + """"click":[633,361],"type":4,"hidden":false}""" + + """],"truncated":false,"cursor":${cursor()}}""", + text("client_widgets"), + ) + } + + @Test + fun `widgets narrows the list by text, by group and to hidden ones`() { + connect() + showDisplayNameInterface() + + fun ids(arguments: String): List = + mapper.readTree(text("client_widgets", arguments)).get("widgets").map { it.get("id").asText() } + + assertEquals(listOf("558:7", "558:12[3]"), ids("""{"text":"NAME"}""")) + assertEquals(listOf("558:7"), ids("""{"text":"select"}""")) + assertEquals(listOf("548:0"), ids("""{"group":548}""")) + assertEquals(listOf("558:7", "558:12[3]", "558:13"), ids("""{"group":558,"hidden":true}""")) + } + + @Test + fun `widgets stops at the limit and says the list was cut short`() { + connect() + showDisplayNameInterface() + + val listed = mapper.readTree(text("client_widgets", """{"limit":2}""")) + + assertEquals(listOf("548:0", "558:7"), listed.get("widgets").map { it.get("id").asText() }) + assertTrue(listed.get("truncated").asBoolean()) + } + + @Test + fun `vars reads each requested id`() { + connect() + game.varps[1055] = 7 + game.varbits[8119] = 1 + game.varcInts[5] = 0 + game.varcStrs[335] = "McpProto" + game.varcStrs[336] = null + + assertEquals( + """{"varps":{"1055":7},"varbits":{"8119":1},"varcInts":{"5":0},""" + + """"varcStrs":{"335":"McpProto","336":null},"cursor":${cursor()}}""", + text("client_vars", """{"varps":[1055],"varbits":[8119],"varcInts":[5],"varcStrs":[335,336]}"""), + ) + } + + @Test + fun `vars reports an id the client does not have as not found`() { + connect() + + val message = failure("client_vars", """{"varbits":[99999]}""") + + assertTrue(message.startsWith("not_found: varbit 99999 could not be read"), message) + } + + @Test + fun `login submits the credentials and returns once the client is in the game`() { + connect() + + assertEquals( + """{"gameState":"LOGGED_IN","cursor":${cursor()}}""", + text("client_login", """{"username":"mcptest","password":"secret"}"""), + ) + assertEquals("mcptest" to "secret", game.username to game.password) + } + + @Test + fun `login waits for a client that is still starting to reach the login screen`() { + connect() + game.startingReads = 2 + + text("client_login", """{"username":"mcptest","password":"secret"}""") + + assertEquals("mcptest", game.username) + assertEquals(GameState.LOGGED_IN, game.gameState) + } + + @Test + fun `a login the server refuses is reported as the wrong state`() { + connect() + game.loginAnswer = GameState.LOGIN_SCREEN + + assertEquals( + "wrong_state: the login was refused; the client is back on the login screen", + failure("client_login", """{"username":"mcptest","password":"wrong"}"""), + ) + } + + @Test + fun `a login times out when the client never reaches the login screen`() { + connect() + game.gameState = GameState.STARTING + + assertEquals( + "timeout: the client did not reach the login screen; gameState is STARTING. " + + "A client that cannot reach the game server stays in this state.", + failure("client_login", """{"username":"mcptest","password":"secret","wait_ms":0}"""), + ) + assertNull(game.username) + } + + @Test + fun `a login times out when the client stays logging in`() { + connect() + game.loginAnswer = GameState.LOGGING_IN + + assertEquals( + "timeout: not logged in before the wait elapsed; gameState is LOGGING_IN", + failure("client_login", """{"username":"mcptest","password":"secret","wait_ms":0}"""), + ) + } + + @Test + fun `a login from past the login screen is refused without touching the credentials`() { + connect() + game.logIn("McpProto") + + assertEquals( + "wrong_state: the client is not on the login screen: LOGGED_IN", + failure("client_login", """{"username":"mcptest","password":"secret"}"""), + ) + assertNull(game.username) + } + + @Test + fun `an empty password is a bad argument`() { + connect() + + assertEquals( + "bad_args: password is required and must not be empty", + failure("client_login", """{"username":"mcptest","password":""}"""), + ) + } + + @Test + fun `a call that waits on the game does not hold up the next one`() { + connect() + game.loginAnswer = GameState.LOGGING_IN + val login = + CompletableFuture.supplyAsync { + text("client_login", """{"username":"mcptest","password":"secret","wait_ms":60000}""") + } + + awaitTrue("the login is submitted") { game.gameState == GameState.LOGGING_IN } + + assertEquals("LOGGING_IN", mapper.readTree(text("client_state")).get("gameState").asText()) + assertFalse(login.isDone) + + game.gameState = GameState.LOGGED_IN + assertEquals("LOGGED_IN", mapper.readTree(login.get(10, TimeUnit.SECONDS)).get("gameState").asText()) + } + + @Test + fun `a click at coordinates moves, presses, releases and clicks the left button there`() { + connect() + + assertEquals("""{"x":380,"y":215,"cursor":${cursor()}}""", text("client_click", """{"x":380,"y":215}""")) + + assertEquals( + listOf( + "moved 380,215 button 0", + "pressed 380,215 button 1 holding 1", + "released 380,215 button 1", + "clicked 380,215 button 1", + ), + game.canvas.events, + ) + } + + @Test + fun `a right click uses the right button`() { + connect() + + text("client_click", """{"x":10,"y":20,"button":"right"}""") + + assertEquals( + listOf( + "moved 10,20 button 0", + "pressed 10,20 button 3 holding 3", + "released 10,20 button 3", + "clicked 10,20 button 3", + ), + game.canvas.events, + ) + } + + @Test + fun `a click on a widget lands on its centre`() { + connect() + showDisplayNameInterface() + + assertEquals("""{"x":5,"y":5,"cursor":${cursor()}}""", text("client_click", """{"widget":"558:7"}""")) + assertEquals("""{"x":633,"y":361,"cursor":${cursor()}}""", text("client_click", """{"widget":"558:12[3]"}""")) + + assertEquals( + listOf("pressed 5,5 button 1 holding 1", "pressed 633,361 button 1 holding 1"), + game.canvas.events.filter { it.startsWith("pressed") }, + ) + } + + @Test + fun `a click on a widget that is missing or hidden is not found and sends no input`() { + connect() + showDisplayNameInterface() + + assertEquals("not_found: widget 558:99 does not exist", failure("client_click", """{"widget":"558:99"}""")) + assertEquals("not_found: widget 558:7[9] does not exist", failure("client_click", """{"widget":"558:7[9]"}""")) + assertEquals("not_found: widget 558:13 is not visible", failure("client_click", """{"widget":"558:13"}""")) + assertEquals(emptyList(), game.canvas.events) + } + + @Test + fun `a click without a usable target is a bad argument`() { + connect() + + assertEquals("bad_args: pass either x and y, or widget", failure("client_click", """{"x":380}""")) + assertEquals( + """bad_args: widget must look like "558:7" or "558:7[3]"""", + failure("client_click", """{"widget":"name field"}"""), + ) + assertEquals(emptyList(), game.canvas.events) + } + + @Test + fun `type presses, types and releases each character, then Enter when asked`() { + connect() + + assertEquals("""{"typed":2,"cursor":${cursor()}}""", text("client_type", """{"text":"m1","enter":true}""")) + + assertEquals( + listOf( + "pressed code ${KeyEvent.VK_M} char ${'m'.code}", + "typed code 0 char ${'m'.code}", + "released code ${KeyEvent.VK_M} char ${'m'.code}", + "pressed code ${KeyEvent.VK_1} char ${'1'.code}", + "typed code 0 char ${'1'.code}", + "released code ${KeyEvent.VK_1} char ${'1'.code}", + "pressed code ${KeyEvent.VK_ENTER} char 10", + "typed code 0 char 10", + "released code ${KeyEvent.VK_ENTER} char 10", + ), + game.canvas.events, + ) + } + + @Test + fun `type without enter sends only the text`() { + connect() + + text("client_type", """{"text":"a"}""") + + assertEquals(3, game.canvas.events.size) + } + + @Test + fun `a screenshot is the next frame, scaled to the canvas so a pixel is a click coordinate`() { + connect() + val dense = BufferedImage(1530, 1006, BufferedImage.TYPE_INT_RGB) + val graphics = dense.createGraphics() + graphics.color = Color.RED + graphics.fillRect(0, 0, 1530, 1006) + graphics.dispose() + game.frame = dense + val cursor = cursor() + + val content = call("client_screenshot").get("content") + + assertEquals("image/png", content[0].get("mimeType").asText()) + val image = ImageIO.read(Base64.getDecoder().decode(content[0].get("data").asText()).inputStream()) + assertEquals(765 to 503, image.width to image.height) + assertEquals(Color.RED.rgb, image.getRGB(380, 215)) + assertEquals("""{"width":765,"height":503,"cursor":$cursor}""", content[1].get("text").asText()) + } + + @Test + fun `an op the plugin does not know is a bad argument`() { + connect() + val link = manager.resolve("s1").requireLink() + + val error = assertFailsWith { link.call("dance", mapper.createObjectNode(), 10_000) } + + assertEquals("bad_args" to "unknown op 'dance'", error.code to error.message) + } + + @Test + fun `every client tool has an op in the plugin`() { + connect() + val clientTools = tools { manager }.filter { it.name.startsWith("client_") } + assertTrue(clientTools.isNotEmpty()) + + for (tool in clientTools) { + val answer = call(tool.name, minimalArguments(tool).toString()).get("content").last().get("text").asText() + + assertFalse(answer.contains("unknown op"), "${tool.name}: $answer") + } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt b/mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt new file mode 100644 index 00000000..54e9e1c6 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt @@ -0,0 +1,282 @@ +package net.rsprox.mcpbridge + +import net.runelite.api.Client +import net.runelite.api.GameState +import net.runelite.api.Menu +import net.runelite.api.MenuAction +import net.runelite.api.MenuEntry +import net.runelite.api.Player +import net.runelite.api.coords.WorldPoint +import net.runelite.api.widgets.Widget +import net.runelite.client.ui.DrawManager +import java.awt.Canvas +import java.awt.Image +import java.awt.Rectangle +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import java.awt.event.KeyListener +import java.awt.event.MouseEvent +import java.awt.event.MouseListener +import java.awt.event.MouseMotionListener +import java.awt.image.BufferedImage +import java.lang.reflect.Proxy +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.function.Consumer + +/** + * An instance of the interface [T] that answers the methods named in [answers] and fails on any other, + * so a test notices a call it did not set up. + */ +internal inline fun fake(vararg answers: Pair) -> Any?>): T { + val byName = answers.toMap() + val type = T::class.java + + return type.cast( + Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { proxy, method, arguments -> + when (method.name) { + in byName -> byName.getValue(method.name)(arguments?.toList().orEmpty()) + "toString" -> "fake ${type.simpleName}" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === arguments[0] + else -> error("the fake ${type.simpleName} has no answer for ${method.name}") + } + }, + ) +} + +internal fun widget( + group: Int, + child: Int, + index: Int = -1, + text: String? = null, + name: String? = null, + actions: List? = null, + bounds: Rectangle = Rectangle(0, 0, 10, 10), + hidden: Boolean = false, + static: List = emptyList(), + dynamic: List = emptyList(), + nested: List? = null, +): Widget = + fake( + "getId" to { (group shl 16) or child }, + "getIndex" to { index }, + "getText" to { text }, + "getName" to { name }, + "getActions" to { actions?.toTypedArray() }, + "getBounds" to { bounds }, + "isHidden" to { hidden }, + "getType" to { 4 }, + "getStaticChildren" to { static.toTypedArray() }, + "getDynamicChildren" to { dynamic.toTypedArray() }, + "getNestedChildren" to { nested?.toTypedArray() }, + "getChild" to { arguments -> dynamic.firstOrNull { it.index == arguments[0] } }, + ) + +internal fun menuEntry( + option: String, + target: String, + type: MenuAction, + id: Int, + param0: Int, + param1: Int, +): MenuEntry = + fake( + "getOption" to { option }, + "getTarget" to { target }, + "getType" to { type }, + "getIdentifier" to { id }, + "getParam0" to { param0 }, + "getParam1" to { param1 }, + ) + +/** A game canvas with no window, which records the input events it is sent. */ +internal class RecordingCanvas( + width: Int, + height: Int, +) : Canvas(), + MouseListener, + MouseMotionListener, + KeyListener { + val events = CopyOnWriteArrayList() + + init { + setSize(width, height) + addMouseListener(this) + addMouseMotionListener(this) + addKeyListener(this) + } + + // AWT only delivers key events to a component that is on screen, and one without a window never is. + override fun isShowing(): Boolean = true + + override fun isDisplayable(): Boolean = true + + private fun mouse( + kind: String, + event: MouseEvent, + ) { + val held = + when { + event.modifiersEx and InputEvent.BUTTON1_DOWN_MASK != 0 -> " holding 1" + event.modifiersEx and InputEvent.BUTTON3_DOWN_MASK != 0 -> " holding 3" + else -> "" + } + + events += "$kind ${event.x},${event.y} button ${event.button}$held" + } + + private fun key( + kind: String, + event: KeyEvent, + ) { + events += "$kind code ${event.keyCode} char ${event.keyChar.code}" + } + + override fun mouseMoved(event: MouseEvent) = mouse("moved", event) + + override fun mousePressed(event: MouseEvent) = mouse("pressed", event) + + override fun mouseReleased(event: MouseEvent) = mouse("released", event) + + override fun mouseClicked(event: MouseEvent) = mouse("clicked", event) + + override fun keyPressed(event: KeyEvent) = key("pressed", event) + + override fun keyTyped(event: KeyEvent) = key("typed", event) + + override fun keyReleased(event: KeyEvent) = key("released", event) + + override fun mouseDragged(event: MouseEvent) { + // + } + + override fun mouseEntered(event: MouseEvent) { + // + } + + override fun mouseExited(event: MouseEvent) { + // + } +} + +/** + * The game as the plugin sees it: a [Client], the thread it must be read on, and the renderer that + * hands out frames. A test sets the fields to describe the game, and reads them to see what the + * plugin did to it. + */ +internal class FakeGame : AutoCloseable { + val canvas = RecordingCanvas(765, 503) + + @Volatile var gameState = GameState.LOGIN_SCREEN + + /** How many more reads of the game state answer STARTING before [gameState] shows. */ + @Volatile var startingReads = 0 + + /** The state the game server puts the client in when it submits a login. */ + @Volatile var loginAnswer = GameState.LOGGED_IN + + @Volatile var username: String? = null + + @Volatile var password: String? = null + + @Volatile var player: Player? = null + + @Volatile var menuOpen = false + + @Volatile var menu = listOf(menuEntry("Cancel", "", MenuAction.CANCEL, 0, 0, 0)) + + @Volatile var roots = emptyList() + + @Volatile var frame = BufferedImage(765, 503, BufferedImage.TYPE_INT_RGB) + + val varps = HashMap() + val varbits = HashMap() + val varcInts = HashMap() + val varcStrs = HashMap() + + private val gameThread = singleThread("fake-client-thread") + private val renderThread = singleThread("fake-render-thread") + + val clientThread: Executor = gameThread + + /** Hands [frame] to each requested listener on the render thread, as the client does after a draw. */ + val frames: DrawManager = + object : DrawManager() { + override fun requestNextFrameListener(listener: Consumer) { + super.requestNextFrameListener(listener) + renderThread.execute { processDrawComplete { frame } } + } + } + + val client: Client = + fake( + "getGameState" to { readGameState() }, + "setGameState" to { arguments -> submitLogin(arguments[0] as GameState) }, + "setUsername" to { arguments -> username = arguments[0] as String }, + "setPassword" to { arguments -> password = arguments[0] as String }, + "getTickCount" to { 0 }, + "getCanvas" to { canvas }, + "getWorld" to { 301 }, + "getLocalPlayer" to { player }, + "isMenuOpen" to { menuOpen }, + "getMenu" to { fake("getMenuEntries" to { menu.toTypedArray() }) }, + "getWidgetRoots" to { roots.toTypedArray() }, + "getWidget" to { arguments -> find(roots, (arguments[0] as Int shl 16) or arguments[1] as Int) }, + // The client indexes its tables with the id and throws for one that does not exist. + "getVarpValue" to { arguments -> varps.getValue(arguments[0] as Int) }, + "getVarbitValue" to { arguments -> varbits.getValue(arguments[0] as Int) }, + "getVarcIntValue" to { arguments -> varcInts.getValue(arguments[0] as Int) }, + "getVarcStrValue" to { arguments -> varcStrs.getValue(arguments[0] as Int) }, + ) + + fun logIn(name: String) { + player = + fake( + "getName" to { name }, + "getWorldLocation" to { WorldPoint(3222, 3218, 0) }, + ) + + gameState = GameState.LOGGED_IN + } + + override fun close() { + gameThread.shutdownNow() + renderThread.shutdownNow() + } + + private fun readGameState(): GameState { + if (startingReads == 0) return gameState + + startingReads-- + + return GameState.STARTING + } + + private fun submitLogin(requested: GameState) { + check(requested == GameState.LOGGING_IN) { "the plugin only ever asks the client to log in" } + + gameState = loginAnswer + } + + private fun find( + widgets: List, + id: Int, + ): Widget? { + for (widget in widgets) { + if (widget.id == id && widget.index < 0) return widget + + val below = find(widget.staticChildren.toList() + widget.nestedChildren.orEmpty(), id) + if (below != null) return below + } + + return null + } + + private fun singleThread(name: String): ExecutorService = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, name).apply { isDaemon = true } + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java index 5918151b..e654888b 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -39,13 +39,13 @@ final class BridgeConnection implements AutoCloseable { private final BufferedWriter writer; private final Gson gson; private final Ops ops; - private final boolean softwareRendering; + private final Rendering rendering; private final AtomicBoolean closed = new AtomicBoolean(); private ExecutorService workers; private BridgeConnection( - Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops, boolean softwareRendering) { - this.softwareRendering = softwareRendering; + Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops, Rendering rendering) { + this.rendering = rendering; this.socket = socket; this.reader = reader; this.writer = writer; @@ -55,18 +55,16 @@ private BridgeConnection( /** * Connects to the rsprox that launched this client. Returns null, having started no thread, when - * there is no rsprox to talk to or it does not expect this client. + * there is no rsprox to talk to or it does not expect this client. A negative {@code httpPort} + * means rsprox did not launch this client. */ - static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { - if (!Files.isRegularFile(rendezvous)) { - return null; - } - - int httpPort = httpPortFromCommandLine(); - if (httpPort < 0) { + static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops ops) { + if (httpPort < 0 || !Files.isRegularFile(rendezvous)) { return null; } + // The protocol spells out an absent player as null, which the client's Gson would drop. + Gson gson = clientGson.newBuilder().serializeNulls().create(); Socket socket = new Socket(); try { @@ -104,10 +102,9 @@ static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { } socket.setSoTimeout(0); - boolean softwareRendering = - answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); - - BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, softwareRendering); + boolean software = answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); + Rendering rendering = software ? Rendering.SOFTWARE : Rendering.GPU; + BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, rendering); connection.start(); log.info("rsprox MCP bridge connected as session {}", answer.get("session")); @@ -125,13 +122,13 @@ static BridgeConnection dial(Path rendezvous, Gson gson, Ops ops) { } } - /** Whether rsprox asked for a client that renders without the GPU plugin. */ - boolean softwareRendering() { - return softwareRendering; + /** {@link Rendering#SOFTWARE} when rsprox asked for a client that renders without the GPU plugin. */ + Rendering rendering() { + return rendering; } /** The port in the client's jav_config argument, which is how rsprox tells its clients apart. */ - private static int httpPortFromCommandLine() { + static int httpPortFromCommandLine() { String command = System.getProperty("sun.java.command", ""); int start = command.indexOf(JAV_CONFIG_PREFIX); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java index 2202a3e2..8cabf2a9 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java @@ -7,12 +7,12 @@ import java.lang.reflect.InvocationTargetException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.function.Consumer; import javax.swing.SwingUtilities; import net.runelite.api.Client; -import net.runelite.client.callback.ClientThread; import net.runelite.client.ui.DrawManager; /** @@ -23,10 +23,10 @@ final class GameAccess { private static final long TIMEOUT_MS = 5_000; private final Client client; - private final ClientThread clientThread; + private final Executor clientThread; private final DrawManager drawManager; - GameAccess(Client client, ClientThread clientThread, DrawManager drawManager) { + GameAccess(Client client, Executor clientThread, DrawManager drawManager) { this.client = client; this.clientThread = clientThread; this.drawManager = drawManager; @@ -37,9 +37,9 @@ interface ClientCall { } /** Runs {@code body} on the client thread and waits for its result. */ - T read(ClientCall body) throws BridgeException { + T onClientThread(ClientCall body) throws BridgeException { CompletableFuture result = new CompletableFuture<>(); - clientThread.invoke(() -> { + clientThread.execute(() -> { try { result.complete(body.call(client)); } catch (Throwable t) { diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index f202e34b..64029deb 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -40,16 +40,16 @@ public class McpBridgePlugin extends Plugin { @Override protected void startUp() { + // The server writes this path in McpMain.kt. Path rendezvous = Paths.get(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"); - Ops ops = new Ops(new GameAccess(client, clientThread, drawManager)); - - // The protocol spells out an absent player as null, which the client's Gson would drop. - connection = BridgeConnection.dial(rendezvous, gson.newBuilder().serializeNulls().create(), ops); - if (connection != null && connection.softwareRendering()) { - Thread thread = new Thread(this::stopGpuPlugin, "mcp-bridge-gpu-off"); - thread.setDaemon(true); - thread.start(); - } + Ops ops = new Ops(new GameAccess(client, clientThread::invoke, drawManager)); + + connection = BridgeConnection.dial(rendezvous, BridgeConnection.httpPortFromCommandLine(), gson, ops); + if (connection == null || connection.rendering() != Rendering.SOFTWARE) return; + + Thread thread = new Thread(this::stopGpuPlugin, "mcp-bridge-gpu-off"); + thread.setDaemon(true); + thread.start(); } /** @@ -95,9 +95,9 @@ private void stopGpuPlugin() { @Override protected void shutDown() { - if (connection != null) { - connection.close(); - connection = null; - } + if (connection == null) return; + + connection.close(); + connection = null; } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 0023e078..99b7e274 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -36,8 +36,6 @@ final class Ops { private static final Pattern WIDGET_REF = Pattern.compile("(\\d+):(\\d+)(?:\\[(\\d+)])?"); private static final int WIDGET_DEPTH_LIMIT = 12; - private static final int DEFAULT_WIDGET_LIMIT = 200; - private static final int DEFAULT_LOGIN_WAIT_MS = 15_000; private static final int LOGIN_POLL_MS = 100; interface Op { @@ -68,7 +66,7 @@ JsonElement run(String op, JsonObject args) throws BridgeException { } private JsonElement state(JsonObject args) throws BridgeException { - return game.read(client -> { + return game.onClientThread(client -> { JsonObject out = new JsonObject(); out.addProperty("gameState", client.getGameState().name()); out.addProperty("tick", client.getTickCount()); @@ -115,19 +113,17 @@ private JsonElement widgets(JsonObject args) throws BridgeException { String text = optionalString(args, "text"); String needle = text == null ? null : text.toLowerCase(Locale.ROOT); boolean includeHidden = optionalBoolean(args, "hidden"); - Integer limit = optionalInt(args, "limit"); - - return game.read(client -> { - WidgetWalk walk = - new WidgetWalk(group, needle, includeHidden, limit == null ? DEFAULT_WIDGET_LIMIT : limit); + int limit = args.get("limit").getAsInt(); + return game.onClientThread(client -> { + WidgetWalk walk = new WidgetWalk(group, needle, includeHidden, limit); Set roots = new LinkedHashSet<>(); for (Widget root : client.getWidgetRoots()) { - if (root != null) { - roots.add(root.getId() >>> 16); - walk.visit(root, 0); - } + if (root == null) continue; + + roots.add(root.getId() >>> 16); + walk.visit(root, 0); } JsonObject out = new JsonObject(); @@ -171,29 +167,32 @@ void visit(Widget widget, int depth) { return; } - if (group == null || widget.getId() >>> 16 == group) { - JsonObject described = describe(widget, hidden); - if (described != null) { - if (found.size() >= limit) { - truncated = true; + collect(widget, hidden); + visitAll(widget.getStaticChildren(), depth); + visitAll(widget.getDynamicChildren(), depth); + visitAll(widget.getNestedChildren(), depth); + } + + private void collect(Widget widget, boolean hidden) { + if (group != null && widget.getId() >>> 16 != group) return; - return; - } + JsonObject described = describe(widget, hidden); + if (described == null) return; - found.add(described); - } + if (found.size() >= limit) { + truncated = true; + + return; } - visitAll(widget.getStaticChildren(), depth); - visitAll(widget.getDynamicChildren(), depth); - visitAll(widget.getNestedChildren(), depth); + found.add(described); } private void visitAll(Widget[] children, int depth) { - if (children != null) { - for (Widget child : children) { - visit(child, depth + 1); - } + if (children == null) return; + + for (Widget child : children) { + visit(child, depth + 1); } } @@ -203,12 +202,10 @@ private JsonObject describe(Widget widget, boolean hidden) { JsonArray actions = new JsonArray(); String[] raw = widget.getActions(); - if (raw != null) { - for (String action : raw) { - if (action != null && !action.isEmpty()) { - actions.add(action); - } - } + for (String action : raw == null ? new String[0] : raw) { + if (action == null || action.isEmpty()) continue; + + actions.add(action); } if (text.isEmpty() && name.isEmpty() && actions.size() == 0) { @@ -244,7 +241,7 @@ private JsonElement vars(JsonObject args) throws BridgeException { int[] varcInts = optionalInts(args, "varcInts"); int[] varcStrs = optionalInts(args, "varcStrs"); - return game.read(client -> { + return game.onClientThread(client -> { JsonObject out = new JsonObject(); out.add("varps", readVars("varp", varps, id -> number(client.getVarpValue(id)))); out.add("varbits", readVars("varbit", varbits, id -> number(client.getVarbitValue(id)))); @@ -276,7 +273,7 @@ private static JsonObject readVars(String kind, int[] ids, VarReader reader) thr private JsonElement screenshot(JsonObject args) throws BridgeException { BufferedImage frame = game.frame(); - int[] canvas = game.read(client -> new int[] {client.getCanvas().getWidth(), client.getCanvas().getHeight()}); + int[] canvas = game.onClientThread(client -> new int[] {client.getCanvas().getWidth(), client.getCanvas().getHeight()}); int width = canvas[0]; int height = canvas[1]; @@ -313,20 +310,14 @@ private JsonElement screenshot(JsonObject args) throws BridgeException { } private JsonElement click(JsonObject args) throws BridgeException { - String button = optionalString(args, "button"); - boolean right = "right".equals(button); - - if (button != null && !right && !"left".equals(button)) { - throw new BridgeException("bad_args", "button must be \"left\" or \"right\""); - } - + boolean right = "right".equals(optionalString(args, "button")); String widget = optionalString(args, "widget"); Integer x = optionalInt(args, "x"); Integer y = optionalInt(args, "y"); Point point; if (widget != null) { - point = game.read(client -> centre(visibleBounds(client, widget))); + point = game.onClientThread(client -> centre(visibleBounds(client, widget))); } else if (x != null && y != null) { point = new Point(x, y); } else { @@ -381,11 +372,7 @@ private static Rectangle visibleBounds(Client client, String ref) throws BridgeE } private JsonElement type(JsonObject args) throws BridgeException { - String text = optionalString(args, "text"); - if (text == null) { - throw new BridgeException("bad_args", "text is required"); - } - + String text = args.get("text").getAsString(); boolean enter = optionalBoolean(args, "enter"); game.input(canvas -> { for (char ch : text.toCharArray()) { @@ -411,20 +398,38 @@ private static void key(Canvas canvas, int code, char ch) { } private JsonElement login(JsonObject args) throws BridgeException { - String username = optionalString(args, "username"); - if (username == null) { - throw new BridgeException("bad_args", "username is required"); - } - + String username = args.get("username").getAsString(); String password = optionalString(args, "password"); if (password == null || password.isEmpty()) { throw new BridgeException("bad_args", "password is required and must not be empty"); } - Integer wait = optionalInt(args, "wait_ms"); - long deadline = System.currentTimeMillis() + (wait == null ? DEFAULT_LOGIN_WAIT_MS : wait); - awaitLoginScreen(deadline); - game.read(client -> { + long deadline = System.currentTimeMillis() + args.get("wait_ms").getAsInt(); + awaitLoaded(deadline); + submitCredentials(username, password); + + return awaitLoggedIn(deadline); + } + + /** The bridge connects while the client is still loading, and a login set before the login screen is lost. */ + private void awaitLoaded(long deadline) throws BridgeException { + while (true) { + GameState state = game.onClientThread(Client::getGameState); + if (state != GameState.STARTING && state != GameState.UNKNOWN) return; + + if (System.currentTimeMillis() >= deadline) { + throw new BridgeException( + "timeout", + "the client did not reach the login screen; gameState is " + state + + ". A client that cannot reach the game server stays in this state."); + } + + pollAgain("the login screen"); + } + } + + private void submitCredentials(String username, String password) throws BridgeException { + game.onClientThread(client -> { GameState state = client.getGameState(); if (state != GameState.LOGIN_SCREEN) { throw new BridgeException("wrong_state", "the client is not on the login screen: " + state); @@ -436,9 +441,11 @@ private JsonElement login(JsonObject args) throws BridgeException { return null; }); + } + private JsonElement awaitLoggedIn(long deadline) throws BridgeException { while (true) { - GameState state = game.read(Client::getGameState); + GameState state = game.onClientThread(Client::getGameState); if (state == GameState.LOGGED_IN) { JsonObject out = new JsonObject(); out.addProperty("gameState", state.name()); @@ -454,36 +461,16 @@ private JsonElement login(JsonObject args) throws BridgeException { throw new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); } - try { - Thread.sleep(LOGIN_POLL_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new BridgeException("internal", "interrupted while waiting for the login"); - } + pollAgain("the login"); } } - /** The bridge connects while the client is still loading, and a login set before the login screen is lost. */ - private void awaitLoginScreen(long deadline) throws BridgeException { - while (true) { - GameState state = game.read(Client::getGameState); - if (state != GameState.STARTING && state != GameState.UNKNOWN) { - return; - } - - if (System.currentTimeMillis() >= deadline) { - throw new BridgeException( - "timeout", - "the client did not reach the login screen; gameState is " + state - + ". A client that cannot reach the game server stays in this state."); - } - - try { - Thread.sleep(LOGIN_POLL_MS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new BridgeException("internal", "interrupted while waiting for the login screen"); - } + private static void pollAgain(String awaited) throws BridgeException { + try { + Thread.sleep(LOGIN_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BridgeException("internal", "interrupted while waiting for " + awaited); } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java new file mode 100644 index 00000000..4df9b462 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java @@ -0,0 +1,7 @@ +package net.rsprox.mcpbridge; + +/** How rsprox wants this client to draw its frames. */ +enum Rendering { + GPU, + SOFTWARE +} From 312b987bb4e46740e185429520ee5205a4dfda24 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 19:59:18 +0200 Subject: [PATCH 15/72] feat(mcp): interact with the game through real input client_interact clicks a target by identity with the real mouse. It finds the target in the client's own lists, moves the mouse onto its clickable shape, reads the menu the client built for that point, and presses only when the client offers the intended action. A non-default option goes through the right-click menu. The loop runs inside the plugin at the client's frame rate, starts on a game tick, and retries until a deadline. A guard on MenuOptionClicked cancels a click that the client resolves to anything else, before a packet is sent. A target out of view makes the loop turn the camera. The server then waits for the client's packet and returns it, so every interaction is proven or reported as dropped. Nothing calls the client's action dispatcher, so the client sends what a player's click sends, mouse packets included. client_entities lists the NPCs, objects, ground items and players near the player with their options. client_camera reads or turns the camera. A dialog target continues a conversation or picks one of its options. Fixes from a review of the branch: - A failed startup closes the endpoint and exits, where it used to leave a process that answered every call with "still starting". - A session whose plugin never connects stops after a deadline with the reason, and a launcher that dies is noticed within a poll interval. - The bridge greets each connection on its own thread, and the plugin dials in the background and no longer blocks client startup. - packets_read matches outside the lock that the transcriber needs. - An explicit null for an optional argument is treated as absent. - The launcher no longer mirrors the proxy's HTTP port base. It retries on the next port when the bind fails. - A widget op on a dialog option is proven by RESUME_PAUSEBUTTON. Every function and property has a docblock, guard clauses are on one line, and the new code follows the spacing rules of the earlier pass. The tests that ran the plugin against a faked game are removed. The fake encoded guesses about how the client builds and lays out its menus, and every defect found in this work was found against a live client. What remains covers the packet log, the endpoint over HTTP, the session failure paths and the bridge handshake. The README gains a checklist to run against a live client after an update. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 124 ++++- mcp/build.gradle.kts | 24 +- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 86 ++- .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 94 +++- .../kotlin/net/rsprox/mcp/bridge/BridgeJar.kt | 10 +- .../net/rsprox/mcp/bridge/BridgeLink.kt | 20 +- .../net/rsprox/mcp/packets/PacketLog.kt | 167 ++++-- .../net/rsprox/mcp/packets/PacketTap.kt | 38 +- .../net/rsprox/mcp/packets/Unfiltered.kt | 38 ++ .../net/rsprox/mcp/server/McpHttpServer.kt | 99 +++- .../net/rsprox/mcp/server/SentPacket.kt | 64 +++ .../kotlin/net/rsprox/mcp/server/Tools.kt | 177 +++++- .../mcp/session/ProxyServiceLauncher.kt | 126 ++++- .../kotlin/net/rsprox/mcp/session/Session.kt | 93 +++- .../net/rsprox/mcp/session/SessionManager.kt | 175 +++++- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 103 +--- .../net/rsprox/mcp/bridge/BridgeJarTest.kt | 78 --- .../net/rsprox/mcp/bridge/FakePlugin.kt | 5 + .../net/rsprox/mcp/packets/PacketLogTest.kt | 17 + .../net/rsprox/mcp/packets/PacketTapTest.kt | 230 -------- .../rsprox/mcp/server/McpDispatcherTest.kt | 204 ------- .../net/rsprox/mcp/server/MinimalArguments.kt | 23 - .../kotlin/net/rsprox/mcp/server/ToolsTest.kt | 350 ------------ .../mcp/session/ProxyServiceLauncherTest.kt | 30 - .../net/rsprox/mcp/session/SessionFixtures.kt | 52 ++ .../mcp/session/SessionManagerRecoveryTest.kt | 193 +++++++ .../rsprox/mcp/session/SessionManagerTest.kt | 301 ---------- .../net/rsprox/mcp/session/SessionTest.kt | 154 ------ .../rsprox/mcpbridge/BridgeEndToEndTest.kt | 522 ------------------ .../kotlin/net/rsprox/mcpbridge/FakeGame.kt | 282 ---------- .../java/net/rsprox/mcpbridge/Aiming.java | 42 ++ .../rsprox/mcpbridge/BridgeConnection.java | 73 ++- .../java/net/rsprox/mcpbridge/BridgeDial.java | 74 +++ .../net/rsprox/mcpbridge/BridgeException.java | 2 + .../java/net/rsprox/mcpbridge/Camera.java | 133 +++++ .../java/net/rsprox/mcpbridge/CameraTurn.java | 99 ++++ .../java/net/rsprox/mcpbridge/ClickGuard.java | 92 +++ .../java/net/rsprox/mcpbridge/Entities.java | 304 ++++++++++ .../java/net/rsprox/mcpbridge/GameAccess.java | 85 ++- .../rsprox/mcpbridge/GroundItemTarget.java | 93 ++++ .../java/net/rsprox/mcpbridge/Interact.java | 31 ++ .../net/rsprox/mcpbridge/Interaction.java | 291 ++++++++++ .../net/rsprox/mcpbridge/McpBridgePlugin.java | 64 ++- .../main/java/net/rsprox/mcpbridge/Mouse.java | 97 ++++ .../java/net/rsprox/mcpbridge/NpcTarget.java | 85 +++ .../net/rsprox/mcpbridge/ObjectTarget.java | 113 ++++ .../main/java/net/rsprox/mcpbridge/Offer.java | 138 +++++ .../main/java/net/rsprox/mcpbridge/Ops.java | 261 +++++---- .../net/rsprox/mcpbridge/PlayerTarget.java | 82 +++ .../java/net/rsprox/mcpbridge/Rendering.java | 10 +- .../java/net/rsprox/mcpbridge/Scenes.java | 107 ++++ .../java/net/rsprox/mcpbridge/Target.java | 65 +++ .../java/net/rsprox/mcpbridge/Targets.java | 305 ++++++++++ .../java/net/rsprox/mcpbridge/TileTarget.java | 72 +++ .../net/rsprox/mcpbridge/WidgetTarget.java | 97 ++++ 55 files changed, 4045 insertions(+), 2649 deletions(-) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt delete mode 100644 mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeDial.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interact.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/TileTarget.java create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java diff --git a/mcp/README.md b/mcp/README.md index 283dcc06..5861f0be 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -8,7 +8,8 @@ checking a private server end to end with a real client. The server has two halves. The proxy half runs in this process and records every packet. The client half is a small RuneLite plugin that the server installs into the client's sideload directory before each launch. The plugin connects back to the server over loopback. The proxy only observes, so it -cannot inject or change packets. All input goes through the client as mouse and key events. +cannot inject or change packets. All input goes through the client as mouse and key events, so the +server receives what it receives from a player. ## Run it @@ -91,7 +92,12 @@ it runs, whatever the clients do. The tools come in three groups. The `session_*` tools launch a client for a target, stop it and list the sessions. `packets_read` reads the decoded packets of a session after a cursor, and can wait for the first packet that matches. The `client_*` tools look at the client and drive it: they read its -state, widgets and variables, take a screenshot, log in, click and type. +state, widgets and variables, take a screenshot, log in, click and type, list what is near the player +and act on it. + +`session_start` gives the launcher of a client 180 seconds to complete its handshake. When the +launcher takes longer, the server abandons the launch and refuses further launches until it is +restarted. The server describes each tool itself. `tools/list` returns every tool with its description and the schema of its arguments: @@ -111,12 +117,83 @@ is server to client, and `P` is a marker that rsprox adds (`CLIENT_LAUNCHED`, `C in every rsprox recording. Read what was typed from the packet that carries the text, such as `RESUME_P_STRINGDIALOG`. -`session_start` gives the launcher of a client 180 seconds to complete its handshake before it -abandons the launch. +## Act on the game world + +Use `client_entities` and `client_interact` for anything in the game world: NPCs, objects, ground +items, other players, walking and dialogs. `client_entities` lists what is near the local player, +nearest first, with the options that each thing offers. Its coordinates are world coordinates, the +ones that the packets carry. An object that covers several tiles is listed once, at its south-west +tile. + +`client_interact` performs one of the listed options, or `Examine` on an NPC, an object or a ground +item, the way a player does. It finds the target in the client's own lists, moves the real mouse onto +it, lets the client build the menu for what is under the mouse, and clicks: a left click when the +option is the default one, otherwise a right click and a click on the option's row. The server +receives exactly what a player's interaction sends, mouse packets included. The client decides what a +click means, so a target that moves after the mouse was aimed would get the wrong click. The plugin +sees the click the client is about to perform, cancels it before anything is sent when it is not the +intended one, and aims again, within the game tick. When no part of the target is on screen it turns +the camera toward the target and keeps trying for up to six seconds. + +The call first checks that the client has the target and that the target offers the option, and +fails with `not_found` when it does not. It then waits for the packet that the client sends and +returns its line as `packet`, with `attempts`, the number of aims it took, `tick`, the client's tick +count when the click went through, `tried`, the last attempts in words, and `camera` when it turned +the camera. + +``` +1. session_start {"target":"My Server"} + -> {"session":"s1","target":"My Server","state":"connected", ...} + +2. client_login {"username":"mcptest","password":"any"} + -> {"gameState":"LOGGED_IN","cursor":2} + +3. client_entities {"kinds":["npc"],"name":"guide"} + -> {"origin":{"x":3094,"y":3107,"plane":0},"npcs":[{"index":0,"id":3308,"name":"Gielinor Guide", + "x":3094,"y":3109,"options":["Talk-to"]}],"objects":[],"ground_items":[],"players":[], + "truncated":false,"cursor":612} + +4. client_interact {"target":"npc","index":0,"option":"Talk-to"} + -> {"expect":"OPNPC","target":"npc","index":0,"id":3308,"name":"Gielinor Guide","option":"Talk-to", + "attempts":1,"tick":41,"tried":["left-clicked Talk-to Gielinor Guide at (312, 171); the client + performed Talk-to Gielinor Guide"],"cursor":613, + "packet":"617 L1 T41 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, ...)"} +``` + +The `packet` in step 4 is the proof that the client sent the action. The two packets before it are +the mouse click and the mouse movement that the client sends for any click. When no such packet +arrives within three seconds, the call fails, because the client dropped the action without a word. +The error names the likely cause: an NPC or a player that left the client's view, or a widget op +that the server has not enabled. What the server answered is in `packets_read {"after":613}`. + +Walking is the same interaction with the option `Walk here` on a tile: +`client_interact {"target":"tile","x":3096,"y":3107}` clicks the tile and is proven by the +`MOVE_GAMECLICK` packet for it. A dialog is driven through its widgets: +`{"target":"dialog","option":"continue"}` clicks the widget that continues it, and +`{"target":"dialog","option":"2"}` or `{"target":"dialog","option":"Yes"}` clicks a choice by its +number or its text. Both are proven by `RESUME_PAUSEBUTTON`. + +When the client never offers the option within the deadline, about three ticks, the call fails with +`wrong_state` and says what the client offered at the last attempt instead, such as +`the client offered [Walk here, Examine Tree]`. An interface that blocks the game view, such as the +bank, gives `the client offers only Cancel; an open interface is blocking the game view`; close it +first. A target that is hidden from every angle, behind a wall for instance, cannot be clicked, and +neither can anything in a secondary world view such as a boat: both tools read only the top-level +world view, on the plane of the local player. + +`client_camera` turns the camera on purpose: pass `yaw` and `pitch` in the client's units, or +`look_at` with the target fields of `client_interact`, and it returns the settled `yaw` and `pitch` +and whether the target is on screen. With no arguments it reads them. + +`client_click` is the unguarded low-level fallback: it clicks a canvas position or the centre of a +widget, and the client performs whatever is under the mouse at that moment. Use it for positions on an +interface that `client_interact` cannot name, and `client_type` for typed input. ## Example: check that setting a display name works -The display-name interface is group 558. Its name field is widget `558:12`. +The display-name interface is group 558. Its name field is widget `558:12`. `558:12` is the text +shown in the field, and it sits on top of `558:7`, the component that receives the click, so the +packet in step 5 reports `558:7`. ``` 1. session_start {"target":"My Server"} @@ -142,7 +219,9 @@ The display-name interface is group 558. Its name field is widget `558:12`. 7. packets_read {"after":484,"prots":["IF_SETTEXT"],"contains":"McpProto","wait_ms":5000} -> {"next":519,"head":519,"dropped":0,"timedOut":false,"count":1} - 519 L1 T8 S IF_SETTEXT [if_settext] com=tutorial_displayname:status (558:13), text="Great! The display name McpProto is available!
You may set this name now, or enter another to look up." + 519 L1 T8 S IF_SETTEXT [if_settext] com=tutorial_displayname:status (558:13), text="Great! The + display name McpProto is available!
You may set this + name now, or enter another to look up." 8. client_widgets {"group":558,"text":"available"} -> {"roots":[...],"widgets":[{"id":"558:13","text":"Great! The display name McpProto @@ -166,3 +245,36 @@ the server sent no such packet for three ticks. After a server restart, call `session_start {"session":"s1"}`. The session keeps its id and its packet log. The client comes back on new ports, and new packets are marked with the next login number. + +## Check it against a live client + +The automated tests cover the packet log, the MCP endpoint and the session lifecycle. They do not run +a game client, so what the client does is checked by hand. Run this after a RuneLite update, a game +revision change, or a change to the plugin. Each step names what proves it. + +Start the server and a session against a target you can log in to, with a new account: + +1. `session_start` returns `"state":"connected"` with a `pid`, and that pid is the client process. +2. `client_login` returns `"gameState":"LOGGED_IN"`, and `packets_read {"origin":"proxy"}` shows a + `LOGIN` row. `packets_read {"limit":20}` shows packets of tick 0, with named values. +3. `client_screenshot` returns an image whose width and height equal `canvas` in `client_state`. +4. `client_interact` on an interface button returns a `packet` line. `packets_read` from the returned + cursor shows an `EVENT_MOUSE_CLICK` row before the button packet, so the click was a real one. +5. `client_type` into a prompt is followed by a packet that carries the typed text. +6. `client_entities` lists the NPCs and objects that the screenshot shows, with their options. +7. `client_interact` with the default option of an NPC that walks around returns its `OPNPC` packet. + The `attempts` count stays low. Repeat it a few times. +8. `client_interact` with an option that is not the default, such as `Examine`, goes through the + right-click menu: the last entry of `tried` starts with `clicked row`. +9. `client_interact` on an object and on a tile each return their packet, and after the walk + `client_state` shows the player on that tile. +10. `client_interact {"target":"dialog","option":"continue"}` advances a conversation. +11. With an interface open that blocks the game view, `client_interact` on an NPC fails and says that + the client offers only Cancel. +12. `client_camera {"look_at":"npc","index":0}` returns `"onScreen":true`, and the next screenshot + shows the turned view. +13. Killing the client process makes `session_list` show `stopped` with the reason `client exited`, + and `session_start` with the same `session` brings it back with the earlier packets still readable. + +A step that fails names what changed. A tool that reports a dropped action or lists what the client +offered instead is working as intended; read its message before suspecting the tool. diff --git a/mcp/build.gradle.kts b/mcp/build.gradle.kts index e2979bc1..8549693c 100644 --- a/mcp/build.gradle.kts +++ b/mcp/build.gradle.kts @@ -4,15 +4,7 @@ val bridgePlugin: Configuration by configurations.creating { isTransitive = false } -repositories { - maven { - url = uri("https://repo.runelite.net") - content { - includeGroupByRegex("net\\.runelite.*") - } - } -} - +// The same repository as in runelite/rsprox-mcp-bridge/build.gradle.kts, for the client the tests run against. dependencies { bridgePlugin(project(":runelite:rsprox-mcp-bridge")) implementation(projects.proxy) @@ -23,23 +15,9 @@ dependencies { implementation(rootProject.libs.bundles.jackson) implementation(rootProject.libs.clikt) implementation(rootProject.libs.inline.logger) - - // The bridge tests run the real plugin against the real server, with only the game faked. - testImplementation(project(":runelite:rsprox-mcp-bridge")) - testImplementation(rootProject.libs.runelite.client) { - // Only what the plugin compiles against. The client's runtime graph adds the game and its natives. - attributes { - attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_API)) - } - - // The client asks for a classifier that the Guice version the proxy brings in does not publish. - exclude(group = "com.google.inject", module = "guice") - } } tasks.test { - systemProperty("java.awt.headless", "true") - // Code under test resolves ~/.rsprox from this property, so no test reads or writes the real one. systemProperty("user.home", temporaryDir.absolutePath) } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index bea7c4f8..54e9644b 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -2,8 +2,8 @@ package net.rsprox.mcp import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.default -import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.switch import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.path import com.github.michaelbull.logging.InlineLogger @@ -22,24 +22,34 @@ import net.rsprox.proxy.ProxyService import java.nio.file.Path import java.util.Locale import java.util.concurrent.atomic.AtomicReference +import kotlin.system.exitProcess /** Runs rsprox without its GUI and serves it to an agent as an MCP server on loopback. */ public class McpCommand : CliktCommand(name = "mcp") { + /** The loopback port of the MCP endpoint. */ private val port by option("--port", help = "Loopback port of the MCP endpoint").int().default(43580) + + /** The number of proxy ports left unused at the start of the range. */ private val portSkip by option( "--port-skip", help = "Proxy ports to leave unused at the start of the range, for a GUI running at the same time", ).int().default(50) + + /** The directory the client sideloads plugins from, or null for the default of the target. */ private val sideloadDir by option( "--sideload-dir", help = "Directory the client sideloads plugins from, when it is not the default of the target", ).path() - private val softwareRendering by option( - "--software-rendering", + + /** The rendering of the launched clients. */ + private val rendering by option( help = "Stop the GPU plugin in launched clients, for a virtual display such as Xvfb", - ).flag() + ).switch("--software-rendering" to Rendering.SOFTWARE).default(Rendering.GPU) + + /** The name of the target to launch at startup, or null to launch none. */ private val autostart by option("--start", help = "Target name to launch immediately") + /** Serve the MCP endpoint, start the proxy and the bridge hub, and run until the process is killed. */ override fun run() { Locale.setDefault(Locale.US) val sessions = AtomicReference() @@ -54,6 +64,34 @@ public class McpCommand : CliktCommand(name = "mcp") { http.start() logger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } + val manager = + try { + closingOnFailure { opened -> + opened += http + startSessions(opened) + } + } catch (t: Throwable) { + // The proxy leaves threads behind that are not daemons, so returning would keep the process alive. + logger.error(t) { "rsprox could not start" } + exitProcess(1) + } + + sessions.set(manager) + logger.info { "Ready. Targets: ${manager.targets().joinToString(", ")}" } + + autostart?.let { target -> + try { + manager.start(target, null, SessionManager.DEFAULT_WAIT_MS) + } catch (e: ToolError) { + logger.error { "Unable to start '$target': ${e.message}" } + } + } + + Thread.currentThread().join() + } + + /** Start the proxy and the bridge hub, and add what must be closed when a later step fails to [opened]. */ + private fun startSessions(opened: MutableList): SessionManager { val service = ProxyService(ByteBufAllocator.DEFAULT) service.start(null, null) { percentage, _, subActionText, _ -> logger.debug { "Starting proxy service: $subActionText (${(percentage * 100).toInt()}%)" } @@ -65,30 +103,44 @@ public class McpCommand : CliktCommand(name = "mcp") { // The plugin reads the same path in McpBridgePlugin.java. val rendezvous = Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json") - val hub = BridgeHub(rendezvous, if (softwareRendering) Rendering.SOFTWARE else Rendering.GPU) + val hub = BridgeHub(rendezvous, rendering) + opened += hub hub.start() // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) - val manager = SessionManager(launcher, service.settingsStore, hub) - sessions.set(manager) - logger.info { "Ready. Targets: ${manager.targets().joinToString(", ")}" } - - autostart?.let { target -> - try { - manager.start(target, null, SessionManager.DEFAULT_WAIT_MS) - } catch (e: ToolError) { - logger.error { "Unable to start '$target': ${e.message}" } - } - } - Thread.currentThread().join() + return SessionManager(launcher, service.settingsStore, hub) } private companion object { + /** The logger of the command. */ private val logger = InlineLogger() } } +/** + * Run the startup steps in [body], and close what they added to the list, newest first, when one of them fails. + * The failure is passed on. + */ +internal fun closingOnFailure(body: (opened: MutableList) -> T): T { + val opened = ArrayList() + + try { + return body(opened) + } catch (t: Throwable) { + for (closeable in opened.asReversed()) { + try { + closeable.close() + } catch (e: Exception) { + t.addSuppressed(e) + } + } + + throw t + } +} + +/** Run the MCP command with the arguments of the process. */ public fun main(args: Array): Unit = McpCommand().main(args) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index c4900b20..a2f308a2 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -23,19 +23,33 @@ internal interface BridgeListener { /** The session id, told to the plugin for its log. */ val session: String - /** Returns false when the session no longer waits for this client; the hub then drops the link. */ + /** + * Take the link of the client that said hello for this session. + * Returns false when the session no longer waits for this client; the hub then drops the link. + */ fun onHello( link: BridgeLink, pid: Long, ): Boolean + /** Hear that the link, which this listener accepted earlier, has closed. */ fun onClosed(link: BridgeLink) + + /** Hear that a hello for this session was rejected for [reason]. The hub keeps expecting the client. */ + fun onRejected(reason: String) } /** How a launched client draws its frames. [SOFTWARE] has the plugin stop the client's GPU plugin. */ public enum class Rendering { + /** Frames drawn by the GPU plugin, when the client's profile enables it. */ GPU, + + /** Frames drawn without the GPU plugin, which the bridge plugin stops. */ SOFTWARE, + ; + + /** Get the `softwareRendering` flag that tells the plugin of this rendering in a welcome. */ + internal fun toWire(): Boolean = this == SOFTWARE } /** @@ -44,17 +58,25 @@ public enum class Rendering { * A client this process did not launch is rejected and stays dormant. */ public class BridgeHub( + /** The file through which a plugin finds this hub. */ private val rendezvous: Path, + /** The rendering that every welcomed client is told to use. */ private val rendering: Rendering = Rendering.GPU, ) : AutoCloseable { + /** The listeners that wait for a client, by the HTTP port of its launch. */ private val expected = ConcurrentHashMap() + + /** The links to the connected clients. */ private val links = ConcurrentHashMap.newKeySet() + + /** The secret that a plugin must repeat from the rendezvous file. */ private val token = newToken() + /** The socket that plugins dial, or null before [start]. */ @Volatile private var server: ServerSocket? = null - /** Binds an ephemeral loopback port and publishes it, with the token, in the rendezvous file. */ + /** Bind an ephemeral loopback port and publish it, with the token, in the rendezvous file. */ public fun start() { val server = ServerSocket(0, BACKLOG, InetAddress.getLoopbackAddress()) this.server = server @@ -66,7 +88,7 @@ public class BridgeHub( thread.start() } - /** Routes the hello that names [httpPort] to [listener]. Must be called before the client is forked. */ + /** Route the hello that names [httpPort] to [listener]. Must be called before the client is forked. */ internal fun expect( httpPort: Int, listener: BridgeListener, @@ -74,12 +96,19 @@ public class BridgeHub( expected[httpPort] = listener } + /** Stop expecting the hello that names [httpPort], so a client that says it later is rejected. Idempotent. */ + internal fun forget(httpPort: Int) { + expected.remove(httpPort) + } + + /** Delete the rendezvous file, stop accepting plugins and drop every link. */ override fun close() { Files.deleteIfExists(rendezvous) server?.close() links.forEach { it.close() } } + /** Publish the port and the token in the rendezvous file, readable only by its owner. */ private fun writeRendezvous(port: Int) { val content = MAPPER @@ -102,6 +131,7 @@ public class BridgeHub( Files.move(temp, rendezvous, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) } + /** Hand each plugin that dials in to a thread of its own, until the server socket closes. */ private fun acceptLoop(server: ServerSocket) { while (true) { val socket = @@ -111,30 +141,45 @@ public class BridgeHub( return } - try { - greet(socket) - } catch (e: Exception) { - logger.debug(e) { "Dropped a bridge connection that did not complete its hello" } - socket.close() - } + // A plugin dials once and gives up after a few seconds, so one silent peer must not hold up the next. + val thread = Thread({ greetOrDrop(socket) }, "mcp-bridge-hello") + thread.isDaemon = true + thread.start() } } + /** Greet the plugin, and drop its connection when it does not complete its hello. */ + private fun greetOrDrop(socket: Socket) { + try { + greet(socket) + } catch (e: Exception) { + logger.debug(e) { "Dropped a bridge connection that did not complete its hello" } + socket.close() + } + } + + /** + * Read the hello of a plugin, then welcome it and hand its link to the session that expects it, or reject it. + * + * @throws IOException when the plugin closes the connection or stays silent before its hello + */ private fun greet(socket: Socket) { socket.soTimeout = HELLO_TIMEOUT_MS val reader = socket.getInputStream().bufferedReader(Charsets.UTF_8) val writer = socket.getOutputStream().bufferedWriter(Charsets.UTF_8) val hello = MAPPER.readTree(reader.readLine() ?: throw IOException("closed before hello")) val httpPort = hello.get("httpPort")?.asInt() ?: -1 - val refusal = - when { - !tokenMatches(hello.get("token")) -> "bad token" - hello.get("hello")?.asInt() != PROTOCOL -> - "bridge protocol ${hello.get("hello")} is not supported; the installed plugin jar is stale" - else -> null - } - if (refusal != null) return reject(socket, writer, refusal) + // Only a caller that holds the token may end a launch, so a stray local connection cannot stop a session. + if (!tokenMatches(hello.get("token"))) return reject(socket, writer, "bad token") + + if (hello.get("hello")?.asInt() != PROTOCOL) { + val refusal = "bridge protocol ${hello.get("hello")} is not supported; the installed plugin jar is stale" + reject(socket, writer, refusal) + expected[httpPort]?.onRejected(refusal) + + return + } val listener = expected.remove(httpPort) if (listener == null) return reject(socket, writer, "no session expects httpPort $httpPort") @@ -152,7 +197,7 @@ public class BridgeHub( .createObjectNode() .put("welcome", PROTOCOL) .put("session", listener.session) - .put("softwareRendering", rendering == Rendering.SOFTWARE), + .put("softwareRendering", rendering.toWire()), ) if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) { @@ -166,6 +211,7 @@ public class BridgeHub( logger.info { "Bridge connected for session ${listener.session} (httpPort $httpPort)" } } + /** Tell the plugin why it is not welcome and close its connection. */ private fun reject( socket: Socket, writer: Writer, @@ -175,12 +221,14 @@ public class BridgeHub( socket.close() } + /** Determine if the candidate carries this hub's token, in constant time. */ private fun tokenMatches(candidate: JsonNode?): Boolean { if (candidate == null || !candidate.isTextual) return false return MessageDigest.isEqual(candidate.asText().toByteArray(), token.toByteArray()) } + /** Write the message as one line. */ private fun Writer.line(message: JsonNode) { write(MAPPER.writeValueAsString(message)) write("\n") @@ -188,12 +236,22 @@ public class BridgeHub( } private companion object { + /** The logger of the hub. */ private val logger = InlineLogger() + + /** The JSON mapper of the wire and the rendezvous file. */ private val MAPPER: ObjectMapper = jacksonObjectMapper() + + /** The version of the wire protocol that this hub speaks. */ private const val PROTOCOL = 1 + + /** The number of plugins that may wait to be accepted. */ private const val BACKLOG = 16 + + /** The longest wait for a plugin to send its hello. */ private const val HELLO_TIMEOUT_MS = 5_000 + /** Generate a random token of 22 URL-safe characters. */ private fun newToken(): String { val bytes = ByteArray(16) SecureRandom().nextBytes(bytes) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt index d5172956..2c35bdd4 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt @@ -7,10 +7,14 @@ import java.nio.file.StandardCopyOption /** Installs the embedded bridge plugin jar into the sideload directory of a target's client. */ public class BridgeJar internal constructor( + /** The sideload directory to use for every target, or null for the default of each target. */ private val overrideDir: Path?, + /** The home directory that holds the client's files. */ private val home: Path, + /** The reader of the jar that this build embeds. */ private val embedded: () -> ByteArray, ) { + /** Create an installer of the jar that ships with this build, for the clients of the current user. */ public constructor(overrideDir: Path?) : this( overrideDir, Path.of(System.getProperty("user.home")), @@ -24,7 +28,7 @@ public class BridgeJar internal constructor( ) /** - * Installs the jar unless the installed one already has the same content. A client that has the + * Install the jar unless the installed one already has the same content. A client that has the * old jar open keeps reading it, because the new one is moved into place rather than written over it. */ public fun installFor(target: ProxyTargetConfig): Path { @@ -34,6 +38,7 @@ public class BridgeJar internal constructor( val wanted = embedded() if (Files.isRegularFile(installed) && Files.readAllBytes(installed).contentEquals(wanted)) return installed + Files.createDirectories(dir) // The client loads every `.jar` in the directory, so the partial file must not look like one. @@ -45,7 +50,10 @@ public class BridgeJar internal constructor( } private companion object { + /** The name of the jar, both as a resource and as installed. */ private const val FILE_NAME = "rsprox-mcp-bridge.jar" + + /** The name of the directory, under the client's own, that plugins are sideloaded from. */ private const val SIDELOAD_DIR = "sideloaded-plugins" } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt index 57604848..f9f4e9bc 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -22,6 +22,7 @@ import java.util.concurrent.atomic.AtomicLong * `timeout`, `internal`) or `closed` when the client went away. */ public class BridgeError( + /** The code that says what kind of failure this is. */ public val code: String, message: String, ) : Exception(message) @@ -33,15 +34,25 @@ public class BridgeError( * matched to its caller by id. */ public class BridgeLink internal constructor( + /** The connection to the plugin. */ private val socket: Closeable, + /** The source of the plugin's replies. */ private val reader: BufferedReader, + /** The sink of the requests. */ private val writer: Writer, + /** The callback that hears, once, that the link has closed. */ private val onClosed: (BridgeLink) -> Unit, ) { + /** The calls that wait for a reply, by request id. */ private val pending = ConcurrentHashMap>() + + /** The source of request ids. */ private val ids = AtomicLong() + + /** Whether the reader has stopped, after which no reply can arrive. */ private val closed = AtomicBoolean() + /** Start the thread that reads the plugin's replies. */ internal fun start() { val thread = Thread(::readLoop, "mcp-bridge-reader") thread.isDaemon = true @@ -49,7 +60,7 @@ public class BridgeLink internal constructor( } /** - * Sends one request and blocks the calling thread for its reply. + * Send one request and block the calling thread for its reply. * * @return the `ok` value of the reply * @throws BridgeError when the plugin answers `err`, when no reply arrives within [timeoutMs] @@ -67,6 +78,7 @@ public class BridgeLink internal constructor( try { // Checked after registering: a close that happens from here on fails this call through `pending`. if (closed.get()) throw closedError() + val request = MAPPER.createObjectNode().put("id", id).put("op", op) request.set("args", args) @@ -87,7 +99,7 @@ public class BridgeLink internal constructor( } } - /** Drops the connection. The reader then fails the pending calls and reports the close. */ + /** Drop the connection. The reader then fails the pending calls and reports the close. */ internal fun close() { try { socket.close() @@ -96,6 +108,7 @@ public class BridgeLink internal constructor( } } + /** Write one line to the plugin. */ @Synchronized private fun send(line: String) { writer.write(line) @@ -103,6 +116,7 @@ public class BridgeLink internal constructor( writer.flush() } + /** Complete each pending call with its reply until the connection ends, then fail the rest and report the close. */ private fun readLoop() { try { while (true) { @@ -135,9 +149,11 @@ public class BridgeLink internal constructor( } } + /** Build the error for a call on a link whose client has gone away. */ private fun closedError(): BridgeError = BridgeError("closed", "the client is no longer connected") private companion object { + /** The JSON mapper of the wire. */ private val MAPPER: ObjectMapper = jacksonObjectMapper() } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt index adc31567..585f184e 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt @@ -7,55 +7,72 @@ import kotlin.concurrent.withLock /** Position in one session's packet log. 0 is before the first record. Monotonic across logins and restarts. */ @JvmInline public value class Cursor( + /** The sequence number of the last record before the position. */ public val seq: Long, ) /** Who produced a record: the game client, the game server, or rsprox itself (lifecycle markers). */ public enum class Origin( + /** The letter that stands for the origin in a rendered packet line. */ public val letter: Char, ) { + /** A packet the game client sent to the server. */ CLIENT('C'), + + /** A packet the game server sent to the client. */ SERVER('S'), + + /** A lifecycle marker that rsprox added. */ PROXY('P'), } public data class PacketRecord( - /** 1-based, contiguous, never reused. */ + /** The position in the log: 1-based, contiguous, never reused. */ val seq: Long, - /** Login epoch within the session, or 0 before the first login. */ + /** The login epoch within the session, or 0 before the first login. */ val login: Int, - /** Server tick within that login. Always 0 for [Origin.PROXY] records. */ + /** The server tick within that login. Always 0 for [Origin.PROXY] records. */ val cycle: Int, + /** The producer of the record. */ val origin: Origin, - /** Upper-case prot name such as `IF_SETTEXT`, or a lifecycle marker such as `LOGIN`. */ + /** The upper-case prot name such as `IF_SETTEXT`, or a lifecycle marker such as `LOGIN`. */ val prot: String, - /** Wall clock at append. Wire packets are flushed once per server tick, so this is tick-granular. */ + /** The wall clock at append. Wire packets are flushed once per server tick, so this is tick-granular. */ val atMs: Long, + /** The decoded packet as text, or the detail of a lifecycle marker. */ val text: String, ) public data class PacketQuery( + /** The cursor to read after. */ val after: Cursor = Cursor(0), - /** Upper-case prot names. Empty matches every prot. */ + /** The upper-case prot names to keep. Empty matches every prot. */ val prots: Set = emptySet(), + /** The one origin to keep, or null to keep every origin. */ val origin: Origin? = null, - /** Case-insensitive substring of the record text. */ + /** The case-insensitive substring that the record text must hold, or null to keep every text. */ val contains: String? = null, + /** The most records to return. */ val limit: Int = DEFAULT_LIMIT, + /** The upper-case prefixes of which a prot name must start with one. Empty matches every prot. */ + val protPrefixes: Set = emptySet(), ) { public companion object { + /** The limit of a query that names none. */ public const val DEFAULT_LIMIT: Int = 200 } } public data class PacketPage( + /** The records that matched, oldest first. */ val packets: List, - /** Pass as `after` next time. Everything up to here was either returned or did not match. */ + /** The cursor to pass as `after` next time. Everything up to here was either returned or did not match. */ val next: Cursor, + /** The cursor of the newest record in the log. */ val head: Cursor, - /** Records after the query's cursor that were evicted before they could be scanned. */ + /** The number of records after the query's cursor that were evicted before they could be scanned. */ val dropped: Long, - /** True only when a wait elapsed with no match. */ + /** Whether a wait elapsed with no match. */ val timedOut: Boolean, ) @@ -64,13 +81,22 @@ public data class PacketPage( * `seq - firstSeq` and a read after a cursor only scans the tail it has not seen. */ public class PacketLog( + /** The most records the log keeps before it evicts the oldest. */ private val capacity: Int = 100_000, ) { + /** The lock that guards the records. */ private val lock = ReentrantLock() + + /** The condition that wakes the readers that wait for a record. */ private val appended = lock.newCondition() + + /** The records that have not been evicted, oldest first. */ private val records = ArrayDeque() + + /** The sequence number of the oldest record that has not been evicted. */ private var firstSeq = 1L + /** Append a record, evict the oldest one when the log is full, and return the new sequence number. */ internal fun append( login: Int, cycle: Int, @@ -92,53 +118,118 @@ public class PacketLog( } /** + * Read the records after the query's cursor that match it. * With `waitMs` of 0 this returns whatever matches now. With a positive `waitMs` and nothing matching, * it blocks until the first match arrives or the wait elapses. */ public fun read( query: PacketQuery, waitMs: Long = 0, - ): PacketPage = - lock.withLock { - val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) - val matches = ArrayList() - - // A cursor past the head can only come from another log; reading from the head keeps it usable. - var scanned = query.after.seq.coerceIn(0, headSeq()) - var dropped = 0L - var timedOut = false - - while (true) { - if (scanned + 1 < firstSeq) { - dropped += firstSeq - (scanned + 1) - scanned = firstSeq - 1 - } + ): PacketPage { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) + val matches = ArrayList() + + // A cursor past the head can only come from another log; reading from the head keeps it usable. + // The records are matched outside the lock, a slice at a time, so a broad query never holds up an append. + var slice = lock.withLock { sliceAfter(query.after.seq.coerceIn(0, headSeq()), headSeq()) } + var scanned = slice.first - 1 + var dropped = slice.dropped + var timedOut = false + + while (true) { + for (record in slice.records) { + if (matches.size >= query.limit) break + + scanned++ + if (query.matches(record)) matches += record + } - while (scanned < headSeq() && matches.size < query.limit) { - scanned++ - val record = records[(scanned - firstSeq).toInt()] - if (query.matches(record)) matches += record - } + val full = matches.size >= query.limit + var target = slice.target + if (full || scanned >= target) { if (matches.isNotEmpty() || waitMs <= 0) break - val remaining = deadline - System.nanoTime() - if (remaining <= 0) { + + val head = awaitAppend(target, deadline) + if (head == null) { timedOut = true break } - appended.awaitNanos(remaining) + target = head } - PacketPage(matches, Cursor(scanned), Cursor(headSeq()), dropped, timedOut) + slice = lock.withLock { sliceAfter(scanned, target) } + scanned = slice.first - 1 + dropped += slice.dropped } + return PacketPage(matches, Cursor(scanned), head(), dropped, timedOut) + } + + /** Get the cursor of the newest record. */ public fun head(): Cursor = lock.withLock { Cursor(headSeq()) } + /** Get the sequence number of the newest record, or one below the first when the log is empty. */ private fun headSeq(): Long = firstSeq + records.size - 1 - private fun PacketQuery.matches(record: PacketRecord): Boolean = - (prots.isEmpty() || record.prot in prots) && - (origin == null || record.origin == origin) && - (contains == null || record.text.contains(contains, ignoreCase = true)) + /** + * Copy the next records after [scanned], up to [target] and at most [SLICE] of them. + * Must be called with the lock held. + */ + private fun sliceAfter( + scanned: Long, + target: Long, + ): Slice { + val first = maxOf(scanned + 1, firstSeq) + val count = (target - first + 1).coerceIn(0, SLICE) + val offset = (first - firstSeq).toInt() + + return Slice(first, first - (scanned + 1), target, List(count.toInt()) { records[offset + it] }) + } + + /** Wait for a record after [head] and return the new head, or null when the deadline passes first. */ + private fun awaitAppend( + head: Long, + deadline: Long, + ): Long? = + lock.withLock { + while (headSeq() <= head) { + val remaining = deadline - System.nanoTime() + if (remaining <= 0) return null + + appended.awaitNanos(remaining) + } + + headSeq() + } + + /** Determine if the record passes the prot, prot prefix, origin and text filters of the query. */ + private fun PacketQuery.matches(record: PacketRecord): Boolean { + if (prots.isNotEmpty() && record.prot !in prots) return false + + if (protPrefixes.isNotEmpty() && protPrefixes.none(record.prot::startsWith)) return false + + if (origin != null && record.origin != origin) return false + + if (contains != null && !record.text.contains(contains, ignoreCase = true)) return false + + return true + } + + private class Slice( + /** The sequence number of the first record of the slice, or of the next record when the slice is empty. */ + val first: Long, + /** The number of records before [first] that were evicted before they could be scanned. */ + val dropped: Long, + /** The sequence number that the scan runs up to before it looks at the head again. */ + val target: Long, + /** The records of the slice, oldest first. */ + val records: List, + ) + + private companion object { + /** The most records copied under the lock at a time. */ + private const val SLICE = 1024L + } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt index 2ae4ef63..bbfc5519 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -17,37 +17,48 @@ import net.rsprox.shared.symbols.SymbolDictionaryProvider * [forSession] returns, so the callbacks of this class itself do nothing. */ internal class PacketTap( + /** The log that the packets of every login go to. */ private val log: PacketLog, + /** The registry of the session's logins. */ private val logins: LoginRegistry, + /** The settings that the packets are formatted with. */ private val settings: SettingSetStore, ) : SessionMonitor { + /** Create the tap of one login, under the next login epoch. */ override fun forSession(header: BinaryHeader): SessionMonitor = LoginTap(log, logins, settings, logins.nextEpoch()) + /** Ignore the login, which the proxy reports to the tap of that login. */ override fun onLogin(header: BinaryHeader) { // } + /** Ignore the logout, which the proxy reports to the tap of that login. */ override fun onLogout(header: BinaryHeader) { // } + /** Ignore the cache, which the proxy hands to the tap of each login. */ override fun onCacheUpdate(cacheProvider: CacheProvider) { // } + /** Ignore the incoming byte rate, which no tool reports. */ override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { // } + /** Ignore the outgoing byte rate, which no tool reports. */ override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { // } + /** Ignore the name, which the proxy reports to the tap of that login. */ override fun onNameUpdate(name: String) { // } + /** Ignore the user ids, which no tool reports. */ override fun onUserInformationUpdate( userId: Long, userHash: Long, @@ -55,6 +66,7 @@ internal class PacketTap( // } + /** Ignore the packet, which the proxy reports to the tap of its login. */ override fun onTranscribe( cycle: Int, property: RootProperty, @@ -64,19 +76,28 @@ internal class PacketTap( } internal class LoginTap( + /** The log that the packets of this login go to. */ private val log: PacketLog, + /** The registry that hears what happens to this login. */ private val logins: LoginRegistry, settings: SettingSetStore, + /** The login epoch that every record of this tap carries. */ private val epoch: Int, ) : SessionMonitor { - // Written on a Netty thread when the transcriber is hooked, read on the transcriber worker. + /** + * The game cache that the formatter looks names up in, or null before the proxy has handed it over. + * Written on a Netty thread when the transcriber is hooked, read on the transcriber worker. + */ @Volatile private var cache: CacheProvider? = null - // The two fields below are only touched on the transcriber worker. + /** The direction of the packet being transcribed. Only touched on the transcriber worker. */ private var direction = StreamDirection.SERVER_TO_CLIENT + + /** Whether a packet of this login has been decoded. Only touched on the transcriber worker. */ private var transcribing = false + /** The formatter that turns a decoded packet into text. */ private val formatter: PropertyTreeFormatter = OmitFilteredPropertyTreeFormatter( PropertyFormatterCollection.default(SymbolDictionaryProvider.get(), settings) { @@ -84,10 +105,12 @@ internal class LoginTap( }, ) + /** Keep the cache for the formatter. */ override fun onCacheUpdate(cacheProvider: CacheProvider) { cache = cacheProvider } + /** Note the direction of the next packet, and on the first one that a transcriber is hooked for this login. */ override fun onPacketDirection(direction: StreamDirection) { this.direction = direction @@ -99,7 +122,10 @@ internal class LoginTap( } } - /** Formats on the worker, while the transcriber's session state still matches the packet. */ + /** + * Append the packet to the log as text. + * Formats on the worker, while the transcriber's session state still matches the packet. + */ override fun onTranscribe( cycle: Int, property: RootProperty, @@ -116,20 +142,24 @@ internal class LoginTap( log.append(epoch, cycle, origin, property.prot.uppercase(), text) } + /** Register the login and mark it in the log. */ override fun onLogin(header: BinaryHeader) { logins.login(epoch, header.revision, header.worldId) log.append(epoch, 0, Origin.PROXY, "LOGIN", "revision=${header.revision} world=${header.worldId}") } + /** Register the logout and mark it in the log. */ override fun onLogout(header: BinaryHeader) { logins.update(epoch) { it.copy(online = false) } log.append(epoch, 0, Origin.PROXY, "LOGOUT", "world=${header.worldId}") } + /** Register the name of the player that logged in. */ override fun onNameUpdate(name: String) { logins.update(epoch) { it.copy(name = name) } } + /** Ignore the user ids, which no tool reports. */ override fun onUserInformationUpdate( userId: Long, userHash: Long, @@ -137,10 +167,12 @@ internal class LoginTap( // } + /** Ignore the incoming byte rate, which no tool reports. */ override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { // } + /** Ignore the outgoing byte rate, which no tool reports. */ override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { // } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt index 06e8aed1..0e953fc5 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt @@ -17,35 +17,47 @@ import net.rsprox.shared.settings.SettingSetStore * and every mutator of the default filter set saves to them. */ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { + /** The number of filter sets, which is always one. */ override val size: Int = 1 + /** Get the one filter set, since this store never makes another. */ override fun create(name: String): PropertyFilterSet = AllEnabled + /** Keep the one filter set and report that nothing was deleted. */ override fun delete(index: Int): PropertyFilterSet? = null + /** Get the one filter set, which sits at index 0. */ override fun get(index: Int): PropertyFilterSet? = if (index == 0) AllEnabled else null + /** Get the one filter set, which is always the active one. */ override fun getActive(): PropertyFilterSet = AllEnabled + /** Ignore the index, since the one filter set is always the active one. */ override fun setActive(index: Int) { // } private object AllEnabled : PropertyFilterSet { + /** Get a creation time of zero, since the set was never saved. */ override fun getCreationTime(): Long = 0 + /** Get the fixed name of the set. */ override fun getName(): String = "unfiltered" + /** Ignore the name, which this in-memory set never shows. */ override fun setName(name: String) { // } + /** Delete nothing, since the set has no file. */ override fun deleteBackingFile() { // } + /** Report the filter as enabled, as every filter is. */ override fun get(filter: PropertyFilter): Boolean = true + /** Ignore the change, so the filter stays enabled. */ override fun set( filter: PropertyFilter, enabled: Boolean, @@ -53,6 +65,7 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { // } + /** Ignore the change, so every filter of the category stays enabled. */ override fun set( category: ProtCategory, enabled: Boolean, @@ -60,6 +73,7 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { // } + /** Ignore the change, so every filter of the direction stays enabled. */ override fun set( streamDirection: StreamDirection, enabled: Boolean, @@ -67,24 +81,30 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { // } + /** Ignore the change, so every filter stays enabled. */ override fun setAll(enabled: Boolean) { // } + /** Keep every filter enabled, in place of the defaults that disable some. */ override fun setDefaults() { // } + /** Get an empty list, since the set has no regex filters. */ override fun getRegexFilters(): List = emptyList() + /** Ignore the filter, so the set stays without regex filters. */ override fun addRegexFilter(regexFilter: RegexFilter) { // } + /** Remove nothing, since the set holds no regex filters. */ override fun removeRegexFilter(regexFilter: RegexFilter) { // } + /** Replace nothing, since the set holds no regex filters. */ override fun replaceRegexFilter( oldRegexFilter: RegexFilter, newRegexFilter: RegexFilter, @@ -92,6 +112,7 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { // } + /** Clear nothing, since the set holds no regex filters. */ override fun clearRegexFilters() { // } @@ -112,6 +133,7 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { * - `HIDE_RS3_LOBBY`: RuneScape 3 lobby packets (`TextRs3Transcriber`). */ internal object TapSettingSetStore : SettingSetStore { + /** The settings that drop output, which this store reports as off. */ private val off = setOf( Setting.SKIP_FIRST_TICK, @@ -124,35 +146,47 @@ internal object TapSettingSetStore : SettingSetStore { Setting.HIDE_RS3_LOBBY, ) + /** The number of setting sets, which is always one. */ override val size: Int = 1 + /** Get the one setting set, since this store never makes another. */ override fun create(name: String): SettingSet = Fixed + /** Keep the one setting set and report that nothing was deleted. */ override fun delete(index: Int): SettingSet? = null + /** Get the one setting set, which sits at index 0. */ override fun get(index: Int): SettingSet? = if (index == 0) Fixed else null + /** Get the one setting set, which is always the active one. */ override fun getActive(): SettingSet = Fixed + /** Ignore the index, since the one setting set is always the active one. */ override fun setActive(index: Int) { // } private object Fixed : SettingSet { + /** Get a creation time of zero, since the set was never saved. */ override fun getCreationTime(): Long = 0 + /** Get the fixed name of the set. */ override fun getName(): String = "mcp" + /** Ignore the name, which this in-memory set never shows. */ override fun setName(name: String) { // } + /** Delete nothing, since the set has no file. */ override fun deleteBackingFile() { // } + /** Determine if the setting is on: its default, unless it is one that drops output. */ override fun get(setting: Setting): Boolean = setting.enabled && setting !in off + /** Ignore the change, so the setting keeps its fixed value. */ override fun set( setting: Setting, enabled: Boolean, @@ -160,6 +194,7 @@ internal object TapSettingSetStore : SettingSetStore { // } + /** Ignore the change, so every setting of the category keeps its fixed value. */ override fun set( category: SettingCategory, enabled: Boolean, @@ -167,6 +202,7 @@ internal object TapSettingSetStore : SettingSetStore { // } + /** Ignore the change, so every setting of the group keeps its fixed value. */ override fun set( group: SettingGroup, enabled: Boolean, @@ -174,10 +210,12 @@ internal object TapSettingSetStore : SettingSetStore { // } + /** Ignore the change, so every setting keeps its fixed value. */ override fun setAll(enabled: Boolean) { // } + /** Keep the fixed values, in place of the defaults that drop output. */ override fun setDefaults() { // } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt index 6b17d271..22bbed64 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -23,35 +23,45 @@ public class ToolError( public sealed interface ToolResult { /** One text block holding [value] as compact JSON. */ public data class Json( + /** The value to serialize. */ val value: Any, ) : ToolResult /** One text block holding [text] as is. */ public data class Text( + /** The text of the block. */ val text: String, ) : ToolResult /** A PNG image block followed by one text block holding [meta] as compact JSON. */ public data class Image( + /** The PNG, encoded as Base64. */ val pngBase64: String, + /** The value that describes the image. */ val meta: Any, ) : ToolResult } public class Tool( + /** The name an MCP client calls the tool by. */ public val name: String, + /** The description that tells an agent what the tool does and returns. */ public val description: String, + /** The JSON Schema of the arguments. */ public val inputSchema: ObjectNode, - /** Receives arguments that already satisfy [inputSchema]. */ + /** The handler, which receives arguments that already satisfy [inputSchema]. */ public val run: (args: ObjectNode) -> ToolResult, ) internal class HttpReply( + /** The HTTP status code. */ val status: Int, + /** The JSON body, or null for a reply without one. */ val body: String?, ) private class RpcError( + /** The JSON-RPC error code. */ val code: Int, message: String, ) : Exception(message) @@ -59,10 +69,13 @@ private class RpcError( /** The MCP protocol without the socket: one HTTP request in, one reply out. */ internal class McpDispatcher( tools: List, + /** The version that the server reports of itself. */ private val version: String, ) { + /** The tools by name, in the order they are listed. */ private val tools = tools.associateBy { it.name } + /** Answer one HTTP request with the reply to its JSON-RPC message. */ fun handle( httpMethod: String, origin: String?, @@ -72,6 +85,7 @@ internal class McpDispatcher( if (origin != null && !isLocalOrigin(origin)) return HttpReply(403, null) if (httpMethod != "POST") return HttpReply(405, null) + val root = try { MAPPER.readTree(body) @@ -79,9 +93,7 @@ internal class McpDispatcher( return HttpReply(400, error(null, PARSE_ERROR, "Parse error")) } - if (root == null || !root.isObject) { - return HttpReply(400, error(null, INVALID_REQUEST, "Expected one JSON-RPC object; batching is unsupported")) - } + if (root !is ObjectNode) return invalidRequest(null, "Expected one JSON-RPC object; batching is unsupported") val id = root.get("id")?.takeUnless { it.isNull } val method = root.get("method")?.takeIf { it.isTextual }?.asText() @@ -90,10 +102,11 @@ internal class McpDispatcher( val isResponse = root.has("result") || root.has("error") if (isResponse) return HttpReply(202, null) - return HttpReply(400, error(id, INVALID_REQUEST, "Missing method")) + return invalidRequest(id, "Missing method") } if (id == null) return HttpReply(202, null) + val result = try { call(method, root.get("params")) @@ -109,6 +122,7 @@ internal class McpDispatcher( return HttpReply(200, MAPPER.writeValueAsString(reply)) } + /** Run the JSON-RPC method. Throws [RpcError] for a method this server does not have. */ private fun call( method: String, params: JsonNode?, @@ -121,6 +135,7 @@ internal class McpDispatcher( else -> throw RpcError(METHOD_NOT_FOUND, "Method not found: $method") } + /** Answer the handshake with the protocol version to speak and what the server offers. */ private fun initialize(params: JsonNode?): JsonNode { val requested = params?.get("protocolVersion")?.asText() val result = MAPPER.createObjectNode() @@ -134,6 +149,7 @@ internal class McpDispatcher( return result } + /** List every tool with its description and the schema of its arguments. */ private fun listTools(): JsonNode { val result = MAPPER.createObjectNode() val list = result.putArray("tools") @@ -149,6 +165,10 @@ internal class McpDispatcher( return result } + /** + * Run the named tool and render its result, or its failure as a result with `isError`. + * Throws [RpcError] when the call names no known tool or its arguments are not an object. + */ private fun callTool(params: JsonNode?): JsonNode { val name = params?.get("name")?.takeIf { it.isTextual }?.asText() @@ -158,6 +178,10 @@ internal class McpDispatcher( val arguments = params.get("arguments")?.takeUnless { it.isNull } ?: MAPPER.createObjectNode() if (arguments !is ObjectNode) throw RpcError(INVALID_PARAMS, "params.arguments must be an object") + + // An explicit null means the argument is absent, for the checks, the tool and the plugin alike. + arguments.retain(arguments.fieldNames().asSequence().filter { arguments.hasNonNull(it) }.toList()) + val result = MAPPER.createObjectNode() val content = try { @@ -182,6 +206,7 @@ internal class McpDispatcher( return result } + /** Turn a tool result into the content blocks of an MCP reply. */ private fun render(result: ToolResult): List = when (result) { is ToolResult.Json -> listOf(textBlock(MAPPER.writeValueAsString(result.value))) @@ -197,6 +222,7 @@ internal class McpDispatcher( ) } + /** Build a text content block. */ private fun textBlock(text: String): ObjectNode = MAPPER .createObjectNode() @@ -204,6 +230,7 @@ internal class McpDispatcher( .put("text", text) /** + * Check the arguments against the schema of the tool. Throws [ToolError] for the first mismatch. * Checks the subset of JSON Schema the tool table uses: `required`, per-property `type`, `enum`, * `minimum`, `maximum`, and `items.type` for arrays. Unknown keys are rejected so a misspelt * argument fails loudly instead of being ignored. @@ -221,40 +248,37 @@ internal class McpDispatcher( } } - for (required in tool.inputSchema.get("required") ?: emptyList()) { - if (!arguments.hasNonNull(required.asText())) { - throw ToolError("${tool.name}: missing required argument '${required.asText()}'") - } + for (required in tool.inputSchema.get("required")?.map { it.asText() }.orEmpty()) { + if (!arguments.hasNonNull(required)) throw ToolError("${tool.name}: missing required argument '$required'") } for ((key, value) in arguments.fields()) { val schema = properties.get(key) + val argument = "${tool.name}: argument '$key'" val type = schema.get("type").asText() - if (!value.hasType(type)) throw ToolError("${tool.name}: argument '$key' must be of type $type") + if (!value.hasType(type)) throw ToolError("$argument must be of type $type") + val itemType = schema.get("items")?.get("type")?.asText() - if (itemType != null && value.any { !it.hasType(itemType) }) { - throw ToolError("${tool.name}: every item of '$key' must be of type $itemType") - } + val mistyped = itemType != null && value.any { !it.hasType(itemType) } + + if (mistyped) throw ToolError("${tool.name}: every item of '$key' must be of type $itemType") val allowed = schema.get("enum") if (allowed != null && allowed.none { it == value }) { val values = allowed.joinToString(", ") { it.asText() } - throw ToolError("${tool.name}: argument '$key' must be one of $values") + throw ToolError("$argument must be one of $values") } val minimum = schema.get("minimum")?.asLong() - if (minimum != null && value.asLong() < minimum) { - throw ToolError("${tool.name}: argument '$key' must be at least $minimum") - } + if (minimum != null && value.asLong() < minimum) throw ToolError("$argument must be at least $minimum") val maximum = schema.get("maximum")?.asLong() - if (maximum != null && value.asLong() > maximum) { - throw ToolError("${tool.name}: argument '$key' must be at most $maximum") - } + if (maximum != null && value.asLong() > maximum) throw ToolError("$argument must be at most $maximum") } } + /** Determine if the node has the JSON Schema type. */ private fun JsonNode.hasType(type: String): Boolean = when (type) { "string" -> isTextual @@ -266,6 +290,7 @@ internal class McpDispatcher( else -> error("Unsupported schema type: $type") } + /** Determine if the Origin header names this machine. */ private fun isLocalOrigin(origin: String): Boolean { val host = try { @@ -277,6 +302,13 @@ internal class McpDispatcher( return host in LOCAL_HOSTS } + /** Build the reply to a message that is not a valid JSON-RPC request. */ + private fun invalidRequest( + id: JsonNode?, + message: String, + ): HttpReply = HttpReply(400, error(id, INVALID_REQUEST, message)) + + /** Build the body of a JSON-RPC error. */ private fun error( id: JsonNode?, code: Int, @@ -294,13 +326,28 @@ internal class McpDispatcher( } internal companion object { + /** The JSON mapper of the MCP endpoint. */ internal val MAPPER: ObjectMapper = jacksonObjectMapper() + + /** The logger of the dispatcher. */ private val logger = InlineLogger() + + /** The MCP protocol versions this server speaks, oldest first. */ private val SUPPORTED = listOf("2025-03-26", "2025-06-18", "2025-11-25") + + /** The hosts an Origin header may name. */ private val LOCAL_HOSTS = setOf("localhost", "127.0.0.1", "[::1]") + + /** The JSON-RPC code for a body that is not JSON. */ private const val PARSE_ERROR = -32700 + + /** The JSON-RPC code for a message that is not a request. */ private const val INVALID_REQUEST = -32600 + + /** The JSON-RPC code for a method this server does not have. */ private const val METHOD_NOT_FOUND = -32601 + + /** The JSON-RPC code for parameters a method cannot use. */ private const val INVALID_PARAMS = -32602 } } @@ -310,18 +357,25 @@ internal class McpDispatcher( * so a tool may block for as long as it needs. */ public class McpHttpServer( + /** The loopback port to bind, or 0 for an ephemeral one. */ private val port: Int, tools: List, version: String, ) : AutoCloseable { + /** The protocol handler that every request goes through. */ private val dispatcher = McpDispatcher(tools, version) + + /** The HTTP server, or null before [start]. */ private var server: HttpServer? = null /** The port the server listens on, which differs from the requested one when that was 0. */ internal val localPort: Int get() = checkNotNull(server) { "the server has not been started" }.address.port - /** Throws [java.net.BindException] when the port is taken, which is how a second instance is refused. */ + /** + * Bind the port and start serving. + * Throws [java.net.BindException] when the port is taken, which is how a second instance is refused. + */ public fun start() { val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0) server.executor = @@ -334,6 +388,7 @@ public class McpHttpServer( this.server = server } + /** Answer one HTTP exchange through the dispatcher, with 404 for any path but the endpoint. */ private fun handle(exchange: HttpExchange) { try { if (exchange.requestURI.path != PATH) { @@ -367,11 +422,13 @@ public class McpHttpServer( } } + /** Stop serving at once, if the server was started. */ override fun close() { server?.stop(0) } private companion object { + /** The path of the MCP endpoint. */ private const val PATH = "/mcp" } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt new file mode 100644 index 00000000..56a985c1 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt @@ -0,0 +1,64 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.node.ObjectNode +import net.rsprox.mcp.packets.Cursor +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketLog +import net.rsprox.mcp.packets.PacketQuery + +/** + * The prot prefixes that prove an interaction, for each expected prefix that is not its own only proof. + * A widget op on a dialog option runs the widget's script, which sends RESUME_PAUSEBUTTON and no button packet. + */ +private val PROVING_PREFIXES = mapOf("IF_BUTTON" to setOf("IF_BUTTON", "IF_SUBOP", "RESUME_PAUSEBUTTON")) + +/** The likely cause of a dropped interaction, by the kind of target the client drops silently. */ +private val DROP_CAUSES = + mapOf( + "npc" to "the NPC left the client's view", + "player" to "the player left the client's view", + "widget" to "the server has not enabled that op on the widget", + ) + +/** The likely cause of a dropped interaction with a target that the client sends a packet for unchecked. */ +private const val DROP_CAUSE = "the client logged out or lost its connection" + +/** + * Add the packet that the client sent for an interaction to the plugin's [answer], as `packet`. + * The packet is the first one from the client after the answer's `cursor` whose prot starts with the + * answer's `expect`. Throws [ToolError] when none arrives within [waitMs], or when a walk went to + * another tile than the one asked for. An answer without `expect` names nothing to wait for. + */ +internal fun confirmSent( + packets: PacketLog, + answer: ObjectNode, + waitMs: Long, +): ObjectNode { + val expect = answer.get("expect")?.asText() ?: return answer + val query = + PacketQuery( + after = Cursor(answer.get("cursor").asLong()), + origin = Origin.CLIENT, + limit = 1, + protPrefixes = PROVING_PREFIXES[expect] ?: setOf(expect), + ) + + val sent = packets.read(query, waitMs).packets.firstOrNull() ?: throw ToolError(dropped(answer, expect, waitMs)) + val tile = "coord=(${answer.get("x")}, ${answer.get("y")}, " + val elsewhere = expect == "MOVE_GAMECLICK" && tile !in sent.text + + if (elsewhere) throw ToolError("the client walked to another tile than the one asked for: ${line(sent)}") + + return answer.put("packet", line(sent)) +} + +/** Build the message for an interaction that the client sent no packet for. */ +private fun dropped( + answer: ObjectNode, + expect: String, + waitMs: Long, +): String { + val cause = DROP_CAUSES[answer.get("target")?.asText()] ?: DROP_CAUSE + + return "the client sent no $expect packet within $waitMs ms, so the action was dropped. Likely cause: $cause." +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index b2ea143c..9dd1f717 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode import net.rsprox.mcp.bridge.BridgeError import net.rsprox.mcp.packets.Cursor import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketLog import net.rsprox.mcp.packets.PacketPage import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.PacketRecord import net.rsprox.mcp.session.SessionManager /** - * The tool table. [sessions] is resolved on every call and may throw a [ToolError] while the proxy + * Build the tool table. [sessions] is resolved on every call and may throw a [ToolError] while the proxy * is still starting. */ public fun tools(sessions: () -> SessionManager): List = @@ -112,7 +114,7 @@ public fun tools(sessions: () -> SessionManager): List = "and `height`.", schema = schema("session" to SESSION), sessions = sessions, - result = { ok -> ToolResult.Image(ok.remove("png")?.asText().orEmpty(), ok) }, + result = { ok, _ -> ToolResult.Image(ok.remove("png")?.asText().orEmpty(), ok) }, ), clientTool( name = "client_widgets", @@ -160,7 +162,8 @@ public fun tools(sessions: () -> SessionManager): List = schema( "session" to SESSION, "username" to string("Account name to log in with."), - "password" to string("Password. It must not be empty. A server that ignores passwords accepts any value."), + "password" to + string("Password. It must not be empty. A server that ignores passwords accepts any value."), "wait_ms" to integer("How long to wait for the login to complete. Default $LOGIN_WAIT_MS.", 0, 120_000), required = listOf("username", "password"), @@ -173,8 +176,11 @@ public fun tools(sessions: () -> SessionManager): List = name = "client_click", description = "Click on the game canvas, either at `x`,`y` in canvas units (the pixels of a " + - "client_screenshot) or at the centre of `widget`. Returns the `x`,`y` that was clicked. " + - "Fails with `not_found` when the widget does not exist or is not visible.", + "client_screenshot) or at the centre of `widget`. This is the low-level fallback: prefer " + + "client_interact, which aims by identity and cancels a click that would hit the wrong " + + "thing. A raw click is not guarded; the client performs whatever is under the mouse. " + + "Returns the `x`,`y` that was clicked. Fails with `not_found` when the widget does not " + + "exist or is not visible.", schema = schema( "session" to SESSION, @@ -199,10 +205,96 @@ public fun tools(sessions: () -> SessionManager): List = ), sessions = sessions, ), + clientTool( + name = "client_entities", + description = + "List what is near the local player on its plane, nearest first: `npcs`, `objects`, " + + "`ground_items` and `players`, each with `name`, its tile `x`,`y` in world coordinates and " + + "`options`, the names that client_interact accepts as `option`. An NPC and a player also " + + "have `index`; an NPC, an object and a ground item have `id`. An object is listed once, at " + + "its tile of origin, and only when it has options. `origin` is the tile of the local " + + "player. `truncated` is true when `limit` cut off the farthest entities; narrow the " + + "list with `kinds`, `radius` or `name`.", + schema = + schema( + "session" to SESSION, + "kinds" to + stringArray("Kinds to list. Default: all.", "npc", "object", "ground_item", "player"), + "radius" to + integer("Greatest distance from the local player in tiles. Default $ENTITY_RADIUS.", 0, 52), + "name" to string("Case-insensitive substring the name must contain."), + "limit" to integer("Maximum number of entities to return. Default $ENTITY_LIMIT.", 1, 2000), + ), + sessions = sessions, + defaults = mapOf("radius" to ENTITY_RADIUS, "limit" to ENTITY_LIMIT), + ), + clientTool( + name = "client_interact", + description = + "Perform one option on one thing in the game as a player does, and wait for the packet " + + "that proves the client sent it. The plugin moves the real mouse onto the target, lets " + + "the client build its menu, and clicks, through the right-click menu when the option is " + + "not the default; it retries within the game tick when the target moved, cancels a " + + "click that would hit anything else before the client sends it, and turns the camera " + + "when the target is out of view. The server receives exactly what a player's click sends, " + + "mouse packets included. Pick the target from client_entities: `npc` or `player` by " + + "`index`; `object` or `ground_item` by `id` and its tile `x`,`y`; `widget` by its " + + "client_widgets id; `dialog` with `option` \"continue\", or the text or 1-based number " + + "of a dialog choice; `tile` by `x`,`y`, which walks there and takes no `option`. " + + "`option` is one of the target's own options, in any case; an NPC, an object and a ground " + + "item also accept \"Examine\", and a widget \"Continue\". Returns what was resolved, " + + "`attempts`, `tick` (the client's tick count when it performed the action), `tried` " + + "(the last attempts in words), `camera` when it was turned, and `packet`, the line of " + + "the packet the client sent as packets_read prints it. Fails with `not_found` when the " + + "client does not have the target or the target does not offer the option, with " + + "`wrong_state` when the client never offered the option within the deadline (about " + + "three ticks, or six seconds after a camera turn), saying what it offered instead, and " + + "with an error that names the likely cause when the client sent no packet.", + schema = + schema( + "session" to SESSION, + "target" to string("Kind of thing to act on.", *TARGET_KINDS, "widget", "dialog"), + "option" to string("Option to perform, such as \"Talk-to\". Required unless `target` is tile."), + "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), + "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), + "x" to integer("World x of the tile of the object, ground item or tile.", 0), + "y" to integer("World y of the tile of the object, ground item or tile.", 0), + "widget" to string("Widget id as listed by client_widgets, such as \"558:7\" or \"558:7[3]\"."), + required = listOf("target"), + ), + sessions = sessions, + timeoutMs = { INTERACTION_TIMEOUT_MS }, + result = { ok, packets -> ToolResult.Json(confirmSent(packets, ok, SENT_WAIT_MS)) }, + ), + clientTool( + name = "client_camera", + description = + "Read or turn the camera. With no arguments it returns the current `yaw` and `pitch`. " + + "Pass `yaw` and/or `pitch`, or `look_at` with the same target fields as client_interact, " + + "to turn it; the call then waits until the camera has settled and returns the resulting " + + "`yaw` and `pitch`, plus `onScreen` for a looked-at target. Angles are in the client's " + + "units: 16384 to a full turn of yaw, where 0 has north up; pitch from 1024 (lowest) to " + + "3064 (highest). client_interact turns the camera itself when it needs to.", + schema = + schema( + "session" to SESSION, + "yaw" to integer("Yaw to turn to.", 0, 16383), + "pitch" to integer("Pitch to turn to.", 1024, 3064), + "look_at" to + string("Kind of target to face, named by the fields of client_interact.", *TARGET_KINDS), + "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), + "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), + "x" to integer("World x of the tile of the object, ground item or tile.", 0), + "y" to integer("World y of the tile of the object, ground item or tile.", 0), + ), + sessions = sessions, + timeoutMs = { INTERACTION_TIMEOUT_MS }, + ), ) /** - * The shape every client_* tool shares. The plugin's answer is the tool result, plus the packet cursor + * Build a client_* tool, which forwards its arguments to the op of the same name in the plugin. + * The plugin's answer is the tool result, plus the packet cursor * taken before the call, so everything the action caused has a sequence number above it. * * The plugin has no defaults of its own: each of [defaults] is forwarded when the caller left it out. @@ -214,7 +306,7 @@ private fun clientTool( sessions: () -> SessionManager, defaults: Map = emptyMap(), timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, - result: (ObjectNode) -> ToolResult = ToolResult::Json, + result: (ok: ObjectNode, packets: PacketLog) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, ): Tool = Tool(name, "$description $CURSOR_NOTE", schema) { args -> val session = sessions().resolve(args.text("session")) @@ -228,21 +320,48 @@ private fun clientTool( val ok = session.requireLink().call(name.removePrefix("client_"), forwarded, timeoutMs(forwarded)) if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") - result(ok.put("cursor", cursor)) + result(ok.put("cursor", cursor), session.packets) } +/** The longest wait for the plugin to answer a call, on top of any wait the call itself asks for. */ private const val CLIENT_CALL_TIMEOUT_MS = 10_000L +/** + * The longest wait for an interaction or a camera turn. It exceeds the plugin's longest deadline, six + * seconds after a camera turn, plus the wait for a tick and for the camera to settle. + */ +private const val INTERACTION_TIMEOUT_MS = 15_000L + +/** The kinds of target that are in the game world, which both client_interact and client_camera accept. */ +private val TARGET_KINDS = arrayOf("npc", "object", "ground_item", "player", "tile") + +/** The wait for a login to complete, unless the caller says otherwise. */ private const val LOGIN_WAIT_MS = 15_000L +/** The most widgets that client_widgets returns, unless the caller says otherwise. */ private const val WIDGET_LIMIT = 200L +/** The greatest distance from the local player that client_entities lists, unless the caller says otherwise. */ +private const val ENTITY_RADIUS = 15L + +/** The most entities that client_entities returns, unless the caller says otherwise. */ +private const val ENTITY_LIMIT = 100L + +/** + * The wait for the packet of an interaction. Packets reach the log in batches at the end of each server + * tick, so the wait spans several ticks. + */ +private const val SENT_WAIT_MS = 3_000L + +/** The sentence that ends the description of every client_* tool. */ private const val CURSOR_NOTE = "The result carries `cursor`, the packet cursor taken just before the call: pass it as `after` to " + "packets_read to see only the packets from this call onwards." +/** The schema of the `session` argument that every tool but session_start and session_list takes. */ private val SESSION: ObjectNode = string("Session id, such as \"s1\". May be omitted while only one session exists.") +/** Render a page as one line of JSON meta followed by one line per packet. */ private fun render(page: PacketPage): String { val meta = linkedMapOf( @@ -256,24 +375,20 @@ private fun render(page: PacketPage): String { val out = StringBuilder(McpDispatcher.MAPPER.writeValueAsString(meta)) for (packet in page.packets) { - out - .append('\n') - .append(packet.seq) - .append(" L") - .append(packet.login) - .append(" T") - .append(packet.cycle) - .append(' ') - .append(packet.origin.letter) - .append(' ') - .append(packet.prot) - .append(' ') - .append(packet.text.replace("\n", "\n ")) + out.append('\n').append(line(packet)) } return out.toString() } +/** Render a packet as its line, with the continuation lines of its text indented. */ +internal fun line(packet: PacketRecord): String { + val text = packet.text.replace("\n", "\n ") + + return "${packet.seq} L${packet.login} T${packet.cycle} ${packet.origin.letter} ${packet.prot} $text" +} + +/** Build the schema of an object with the given properties. */ private fun schema( vararg properties: Pair, required: List = emptyList(), @@ -294,8 +409,10 @@ private fun schema( return schema } +/** Build the schema of a boolean argument. */ private fun boolean(description: String): ObjectNode = property("boolean", description) +/** Build the schema of an argument that is an array of integers. */ private fun integerArray(description: String): ObjectNode { val node = property("array", description) node.putObject("items").put("type", "integer") @@ -303,6 +420,7 @@ private fun integerArray(description: String): ObjectNode { return node } +/** Build the schema of a string argument, limited to the allowed values when any are given. */ private fun string( description: String, vararg allowed: String, @@ -317,6 +435,7 @@ private fun string( return node } +/** Build the schema of an integer argument within the given bounds. */ private fun integer( description: String, minimum: Long, @@ -330,13 +449,23 @@ private fun integer( return node } -private fun stringArray(description: String): ObjectNode { +/** Build the schema of an argument that is an array of strings, limited to the allowed values when any are given. */ +private fun stringArray( + description: String, + vararg allowed: String, +): ObjectNode { val node = property("array", description) - node.putObject("items").put("type", "string") + val items = node.putObject("items").put("type", "string") + + if (allowed.isNotEmpty()) { + val values = items.putArray("enum") + allowed.forEach(values::add) + } return node } +/** Build the schema of an argument with the given type and description. */ private fun property( type: String, description: String, @@ -346,6 +475,8 @@ private fun property( .put("type", type) .put("description", description) +/** Get the named argument as text, or null when it is absent. */ private fun ObjectNode.text(name: String): String? = get(name)?.asText() +/** Get the named argument as a long, or null when it is absent. */ private fun ObjectNode.long(name: String): Long? = get(name)?.asLong() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index c94c1293..85c6f93d 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -3,47 +3,73 @@ package net.rsprox.mcp.session import net.rsprox.mcp.bridge.BridgeJar import net.rsprox.proxy.ProxyService import net.rsprox.proxy.connection.ClientTypeDictionary +import net.rsprox.proxy.target.ProxyTarget import net.rsprox.proxy.target.ProxyTargetConfig import java.io.IOException +import java.net.BindException import java.net.ServerSocket +import java.util.concurrent.CompletionException +import java.util.stream.Collectors -/** Must be created right after [ProxyService.start], before anything else allocates a port. */ +/** Launches clients through a started [ProxyService]. */ internal class ProxyServiceLauncher( + /** The proxy that launches the clients. */ private val service: ProxyService, portSkip: Int, + /** The installer of the bridge plugin. */ private val bridgeJar: BridgeJar, ) : ClientLauncher { - // The proxy keeps its first proxy port private, and its HTTP ports are offsets from it. - // The first allocation after start() returns that port. - private val basePort = service.allocatePort() - init { // Leaves the low ports to a GUI that shares the same configured range. - repeat(portSkip - 1) { service.allocatePort() } + repeat(portSkip) { service.allocatePort() } } + /** Get the proxy targets the proxy is configured with. */ override fun targets(): List = service.proxyTargets + /** Install the bridge plugin, pick a free proxy port and bind the HTTP server of the target. */ override fun reserve(target: ProxyTargetConfig): Reservation { bridgeJar.installFor(target) // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. - val port = firstFreePort(service::allocatePort, { HTTP_PORT_BASE + (it - basePort) }, ::canBind) - val proxyTarget = service.initializeHttpServer(port, target) + val (port, proxyTarget) = firstBound(service::allocatePort, ::canBind) { bindHttpServer(it, target) } + val forks = ForkWatch() - return Reservation(port, proxyTarget.httpPort) { monitor -> - service.launchRuneLiteClient(monitor, null, port, proxyTarget) + return Reservation( + port, + proxyTarget.httpPort, + launch = { monitor -> + service.launchRuneLiteClient(monitor, null, port, proxyTarget) - // Probing leaves a window in which another process can take the port. The proxy registers - // a client type for a port only after it has bound it. - check(hasClientType(port)) { "proxy port $port could not be bound" } - } + // Probing leaves a window in which another process can take the port. The proxy registers + // a client type for a port only after it has bound it. + check(hasClientType(port)) { "proxy port $port could not be bound" } + }, + launcherExited = forks::allExited, + ) } + /** Kill the client on the proxy port, if any, and release the proxy state held for it. */ override fun kill(proxyPort: Int) { service.killAliveProcess(proxyPort) } + /** Bind the HTTP server that belongs to the proxy port, or return null when its port is taken. */ + private fun bindHttpServer( + port: Int, + target: ProxyTargetConfig, + ): ProxyTarget? = + try { + service.initializeHttpServer(port, target) + } catch (e: CompletionException) { + // ProxyTarget.launchHttpServer joins the bind, which fails with the cause wrapped. Nothing + // of the target is kept by the proxy at that point, so the next port starts clean. + if (e.cause !is BindException) throw e + + null + } + + /** Determine if the proxy registered a client type for the port, which it does once it has bound it. */ private fun hasClientType(port: Int): Boolean = try { ClientTypeDictionary[port] @@ -52,23 +78,65 @@ internal class ProxyServiceLauncher( false } - private companion object { - // Mirrors net.rsprox.proxy.config.HTTP_SERVER_PORT, which is internal to the proxy module. - private const val HTTP_PORT_BASE = 43600 - } + /** Determine if the port can be bound right now, which it cannot while a process holds it. */ + private fun canBind(port: Int): Boolean = + try { + ServerSocket(port).close() + true + } catch (e: IOException) { + false + } } -/** The first port from [allocate] that is free together with the HTTP port that belongs to it. */ -internal fun firstFreePort( +/** The most proxy ports that one reservation tries. */ +private const val BIND_ATTEMPTS = 64 + +/** + * Get the first port from [allocate] that is free and that [bind] accepts, with what [bind] made for it. + * [bind] returns null for a port whose companion port is taken. Throws when no port in [attempts] works. + */ +internal fun firstBound( allocate: () -> Int, - httpPortOf: (Int) -> Int, isFree: (Int) -> Boolean, -): Int = generateSequence(allocate).first { isFree(it) && isFree(httpPortOf(it)) } - -internal fun canBind(port: Int): Boolean = - try { - ServerSocket(port).close() - true - } catch (e: IOException) { - false + attempts: Int = BIND_ATTEMPTS, + bind: (Int) -> T?, +): Pair { + repeat(attempts) { + val port = allocate() + val bound = if (isFree(port)) bind(port) else null + + if (bound != null) return port to bound + } + + throw IllegalStateException("none of $attempts proxy ports in a row could be bound with its HTTP port") +} + +/** + * The processes this JVM forks from the moment the watch is made. Launches take turns, so the forks that + * follow a reservation are those of its launch. + */ +internal class ForkWatch { + /** The children that were running before the launch. */ + private val before = children() + + /** The processes of the launch seen so far. An exited child is no longer listed, so they are remembered. */ + private val seen = HashSet() + + /** + * Determine if every process forked since the watch was made has exited. False until one has been seen. + * A process that starts and exits between two calls is never seen. + */ + fun allExited(): Boolean { + for (child in children() - before) { + seen += child + + // A launcher may hand over to a client of its own and exit, which is not the launch dying. + child.descendants().forEach { seen += it } + } + + return seen.isNotEmpty() && seen.none { it.isAlive } } + + /** Get the direct children of this JVM that are running. */ + private fun children(): Set = ProcessHandle.current().children().collect(Collectors.toSet()) +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 80cc4d6a..cd6a3b95 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -13,32 +13,42 @@ import kotlin.concurrent.withLock /** Stable for the life of the MCP process. Survives client restarts. Never a port. */ @JvmInline public value class SessionId( + /** The id as callers write it, such as `s1`. */ public val value: String, ) { + /** Get the id as callers write it. */ override fun toString(): String = value } /** One client process generation of a session. A restart makes a new launch on new ports. */ public data class Launch( + /** The number of this launch within its session, starting at 1. */ val generation: Int, + /** The port the client reaches the proxy on. */ val proxyPort: Int, /** The identity the in-client bridge reports when it connects. */ val httpPort: Int, + /** The wall clock at which the launch began. */ val startedAtMs: Long, ) public sealed interface ClientState { public data class Stopped( + /** The reason the session has no client. */ val reason: String, ) : ClientState public data class Launching( + /** The launch whose client has not connected yet. */ val launch: Launch, ) : ClientState public data class Connected( + /** The launch that the client belongs to. */ val launch: Launch, + /** The link to the plugin in the client. */ val link: BridgeLink, + /** The process id of the client. */ val pid: Long, ) : ClientState } @@ -46,25 +56,38 @@ public sealed interface ClientState { /** Everything that can change a session's [ClientState]. Several threads raise these; [reduce] decides. */ public sealed interface SessionEvent { public data class Launched( + /** The launch that began. */ val launch: Launch, ) : SessionEvent public data class Hello( + /** The HTTP port of the launch that the client says it belongs to. */ val httpPort: Int, + /** The link to the plugin that said hello. */ val link: BridgeLink, + /** The process id of the client. */ val pid: Long, ) : SessionEvent public data class LinkClosed( + /** The link that closed. */ val link: BridgeLink, ) : SessionEvent public data class Stop( + /** The reason the client is stopped. */ + val reason: String, + ) : SessionEvent + + public data class NeverConnected( + /** The launch whose client will not connect any more. */ + val launch: Launch, + /** The reason the client will not connect. */ val reason: String, ) : SessionEvent } -/** Returns [state] itself when the event is stale or does not apply. */ +/** Get the state that follows [state]. Returns [state] itself when the event is stale or does not apply. */ internal fun reduce( state: ClientState, event: SessionEvent, @@ -86,29 +109,49 @@ internal fun reduce( } is SessionEvent.Stop -> if (state is ClientState.Stopped) state else ClientState.Stopped(event.reason) + is SessionEvent.NeverConnected -> + if (state is ClientState.Launching && state.launch == event.launch) { + ClientState.Stopped(event.reason) + } else { + state + } } public data class LoginInfo( + /** The number of this login within its session, starting at 1. */ val epoch: Int, + /** The game revision of the client. */ val revision: Int, + /** The world that was logged in to. */ val world: Int, + /** The name of the player, or null until the proxy reports it. */ val name: String?, + /** Whether the login is still in the game. */ val online: Boolean, - /** False while online means no decoder was hooked for this login, so its packets never reach the log. */ + /** + * Whether a packet of this login has been decoded. + * False while online means no decoder was hooked for this login, so its packets never reach the log. + */ val transcribing: Boolean, ) /** The newest login of a session. Updates from an older login are ignored. */ internal class LoginRegistry { + /** The epoch handed to the newest login. */ private var epoch = 0 + + /** The newest login, or null before the first one. */ private var info: LoginInfo? = null + /** Get the epoch for a login that is about to begin. */ @Synchronized fun nextEpoch(): Int = ++epoch + /** Get the newest login, or null before the first one. */ @Synchronized fun current(): LoginInfo? = info + /** Register that the login of the given epoch is in the game, unless a newer login is known. */ @Synchronized fun login( epoch: Int, @@ -117,6 +160,7 @@ internal class LoginRegistry { ) { val current = info if (current != null && current.epoch > epoch) return + info = if (current != null && current.epoch == epoch) { current.copy(online = true) @@ -125,6 +169,7 @@ internal class LoginRegistry { } } + /** Apply the change to the newest login when it is the one of the given epoch. */ @Synchronized fun update( epoch: Int, @@ -138,37 +183,61 @@ internal class LoginRegistry { /** What the session tools return. */ @JsonInclude(JsonInclude.Include.NON_NULL) public data class SessionSnapshot( + /** The id of the session. */ val session: String, + /** The name of the proxy target the session belongs to. */ val target: String, - /** `stopped`, `launching` or `connected`. */ + /** The state of the client: `stopped`, `launching` or `connected`. */ val state: String, + /** The reason the session has no client, or null while it has one. */ val reason: String?, + /** The number of the current launch, or null while stopped. */ val generation: Int?, + /** The proxy port of the current launch, or null while stopped. */ val proxyPort: Int?, + /** The HTTP port of the current launch, or null while stopped. */ val httpPort: Int?, + /** The process id of the client, or null unless connected. */ val pid: Long?, + /** The newest login, or null before the first one. */ val login: LoginInfo?, + /** The packet cursor of the newest record in the session's log. */ val cursor: Long, ) public class Session internal constructor( + /** The id that callers name the session by. */ public val id: SessionId, + /** The proxy target that every launch of the session is for. */ public val target: ProxyTargetConfig, ) { - /** Owned by the session rather than a launch, so a restart keeps the records and the cursor space. */ + /** The packet log. Owned by the session, not a launch, so a restart keeps the records and the cursor space. */ public val packets: PacketLog = PacketLog() + + /** The logins of the session. */ internal val logins = LoginRegistry() + + /** The lock that makes each state transition and its marker one step. */ private val lock = ReentrantLock() + + /** The condition that wakes the callers that wait for the client to connect. */ private val changed = lock.newCondition() + + /** The number of launches so far. */ private var generations = 0 + /** The state of the client, which only [apply] changes. */ @Volatile public var client: ClientState = ClientState.Stopped("not started") private set + /** Get the generation number for a launch that is about to begin. */ internal fun nextGeneration(): Int = lock.withLock { ++generations } - /** The only writer of [client]. Every real transition leaves a marker in the packet log. */ + /** + * Apply the event and return the state that follows. + * The only writer of [client]. Every real transition leaves a marker in the packet log. + */ internal fun apply(event: SessionEvent): ClientState = lock.withLock { val before = client @@ -183,7 +252,15 @@ public class Session internal constructor( after } - /** Blocks while the client is launching, for at most [waitMs]. Never throws on timeout. */ + /** Apply the event and determine if it changed the state. */ + internal fun changes(event: SessionEvent): Boolean = + lock.withLock { + val before = client + + apply(event) !== before + } + + /** Block while the client is launching, for at most [waitMs]. Never throws on timeout. */ internal fun awaitConnected(waitMs: Long): ClientState = lock.withLock { var remaining = TimeUnit.MILLISECONDS.toNanos(waitMs) @@ -194,7 +271,7 @@ public class Session internal constructor( client } - /** The link of the connected client, or a [ToolError] that says what to do about its absence. */ + /** Get the link of the connected client. Throws a [ToolError] that says what to do about its absence. */ internal fun requireLink(): BridgeLink = when (val state = client) { is ClientState.Connected -> state.link @@ -204,6 +281,7 @@ public class Session internal constructor( throw ToolError("session $id has no connected client: ${state.reason}") } + /** Get what the session tools report of the session at this moment. */ public fun snapshot(): SessionSnapshot { val state = client val launch = @@ -232,6 +310,7 @@ public class Session internal constructor( ) } + /** Append the lifecycle marker of the state to the packet log. */ private fun mark(state: ClientState) { val (prot, text) = when (state) { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index a733ac43..4a9fea8d 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -10,40 +10,65 @@ import net.rsprox.proxy.target.ProxyTargetConfig import net.rsprox.shared.SessionMonitor import net.rsprox.shared.settings.SettingSetStore import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference /** The part of the proxy a session needs. Narrow so the lifecycle can be exercised without a real client. */ internal interface ClientLauncher { + /** Get the proxy targets a client can be launched for. */ fun targets(): List - /** Picks free ports and binds the HTTP server of [target]. Throws when the target cannot be prepared. */ + /** Pick free ports and bind the HTTP server of [target]. Throws when the target cannot be prepared. */ fun reserve(target: ProxyTargetConfig): Reservation - /** Kills the client on [proxyPort], if any, and releases the proxy state held for it. Idempotent. */ + /** Kill the client on [proxyPort], if any, and release the proxy state held for it. Idempotent. */ fun kill(proxyPort: Int) } internal class Reservation( + /** The port the client reaches the proxy on. */ val proxyPort: Int, + /** The port the client fetches its configuration from. */ val httpPort: Int, - /** Starts the client and blocks until its launcher has completed the handshake. Throws on failure. */ + /** The call that starts the client and blocks until its launcher has completed the handshake. Throws on failure. */ val launch: (monitor: SessionMonitor) -> Unit, + /** + * The check of whether every process that [launch] forked has exited. False until one has been seen. + * Asked again and again while [launch] blocks, always by the same thread. + */ + val launcherExited: () -> Boolean, ) public class SessionManager internal constructor( + /** The part of the proxy that launches and kills clients. */ private val launcher: ClientLauncher, + /** The settings that the packets of every session are formatted with. */ private val settings: SettingSetStore, + /** The hub that the launched clients dial. */ private val bridge: BridgeHub, + /** The longest wait for a launcher to complete its handshake. */ private val launchTimeoutMs: Long = DEFAULT_LAUNCH_TIMEOUT_MS, + /** The longest wait for the plugin of a launched client to say hello. */ + private val helloTimeoutMs: Long = DEFAULT_HELLO_TIMEOUT_MS, ) { + /** The sessions in the order they were first launched. */ private val sessions = CopyOnWriteArrayList() - // The proxy's launch path is not re-entrant, so launches and stops take turns. + /** The lock that makes launches and stops take turns, since the proxy's launch path is not re-entrant. */ private val launchLock = Any() + + /** Whether a launch never returned, after which the proxy cannot launch again. Guarded by [launchLock]. */ private var hungLaunch = false + /** The timer that stops each launch whose plugin never says hello. */ + private val deadlines = + Executors.newSingleThreadScheduledExecutor { task -> + Thread(task, "mcp-hello-deadline").apply { isDaemon = true } + } + /** - * Creates a session, or relaunches a stopped one. Calling it again for a session that is launching + * Create a session, or relaunch a stopped one. Calling it again for a session that is launching * or connected launches nothing and only waits, so a caller can extend a wait that elapsed. */ public fun start( @@ -54,12 +79,11 @@ public class SessionManager internal constructor( val started = synchronized(launchLock) { val existing = session?.let { resolve(it) } - if (existing != null && target != null && !existing.target.name.equals(target, ignoreCase = true)) { - throw ToolError("session ${existing.id} belongs to target '${existing.target.name}'") - } + if (existing != null) requireTarget(existing, target) val current = existing ?: Session(SessionId("s${sessions.size + 1}"), resolveTarget(target)) if (current.client is ClientState.Stopped) launch(current) + current } @@ -68,7 +92,7 @@ public class SessionManager internal constructor( return started.snapshot() } - /** Kills the client. The session and its packets stay listed and readable. Idempotent. */ + /** Kill the client. The session and its packets stay listed and readable. Idempotent. */ public fun stop(session: String?): SessionSnapshot { val resolved = resolve(session) @@ -82,7 +106,7 @@ public class SessionManager internal constructor( is ClientState.Stopped -> { // } - is ClientState.Launching -> launcher.kill(state.launch.proxyPort) + is ClientState.Launching -> release(state.launch) is ClientState.Connected -> { state.link.close() launcher.kill(state.launch.proxyPort) @@ -93,9 +117,13 @@ public class SessionManager internal constructor( return resolved.snapshot() } + /** Get a snapshot of every session. */ public fun list(): List = sessions.map { it.snapshot() } - /** A null [ref] means the only session. */ + /** + * Get the session with the given id. A null [ref] means the only session. + * Throws [ToolError] when no session matches, or when [ref] is null and several exist. + */ public fun resolve(ref: String?): Session { if (ref != null) { return sessions.firstOrNull { it.id.value == ref } @@ -107,10 +135,23 @@ public class SessionManager internal constructor( return sessions.singleOrNull() ?: throw ToolError("several sessions exist; pass one of: ${sessionIds()}") } + /** Get the names of the proxy targets. */ public fun targets(): List = launcher.targets().map { it.name } + /** List the session ids for an error message. */ private fun sessionIds(): String = sessions.joinToString(", ") { it.id.value }.ifEmpty { "none" } + /** Throw a [ToolError] when the caller names a target other than the one the session belongs to. */ + private fun requireTarget( + session: Session, + target: String?, + ) { + if (target == null || session.target.name.equals(target, ignoreCase = true)) return + + throw ToolError("session ${session.id} belongs to target '${session.target.name}'") + } + + /** Get the target with the given name, or the first custom target for null. Throws [ToolError] for no match. */ private fun resolveTarget(name: String?): ProxyTargetConfig { val targets = launcher.targets() @@ -123,10 +164,12 @@ public class SessionManager internal constructor( ?: throw ToolError("no target '$name'. Targets: ${targets.joinToString(", ") { it.name }}") } + /** + * Launch a client for the session and wait until its launcher has completed the handshake. + * Throws [ToolError] when the launch fails or hangs. Must be called with [launchLock] held. + */ private fun launch(session: Session) { - if (hungLaunch) { - throw ToolError("an earlier launch never completed its handshake; restart the rsprox MCP process") - } + if (hungLaunch) throw ToolError(HUNG_LAUNCH) val reservation = try { @@ -157,26 +200,88 @@ public class SessionManager internal constructor( thread.isDaemon = true thread.start() - thread.join(launchTimeoutMs) + val exited = awaitLaunch(thread, reservation.launcherExited) - val hung = thread.isAlive - val reason = if (hung) "launcher never completed its handshake" else failure.get()?.let(::rootMessage) - if (reason == null) return + hungLaunch = thread.isAlive - if (hung) hungLaunch = true + val reason = + when { + !hungLaunch -> failure.get()?.let(::rootMessage) + exited -> "the launcher exited before completing its handshake" + else -> "launcher never completed its handshake" + } - launcher.kill(launch.proxyPort) + if (reason == null) return expectHello(session, launch) + + release(launch) session.apply(SessionEvent.Stop(reason)) throw ToolError("session ${session.id} failed to launch: $reason") } + /** + * Wait for the launch thread to end, and determine if the wait was cut short because the launcher exited. + * The wait also ends, with false, when the launch timeout passes. + */ + private fun awaitLaunch( + thread: Thread, + launcherExited: () -> Boolean, + ): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(launchTimeoutMs) + + while (thread.isAlive) { + val remainingMs = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()) + if (remainingMs <= 0) return false + + if (launcherExited()) { + // A launcher that exits right after its handshake leaves the launch a moment from returning. + thread.join(LAUNCHER_EXIT_GRACE_MS) + + return true + } + + thread.join(minOf(remainingMs, LAUNCH_POLL_MS)) + } + + return false + } + + /** Give the plugin of the launched client a bounded time to say hello. */ + private fun expectHello( + session: Session, + launch: Launch, + ) { + val overdue = Runnable { abandon(session, launch, NEVER_CONNECTED) } + + deadlines.schedule(overdue, helloTimeoutMs, TimeUnit.MILLISECONDS) + } + + /** Stop the session for [reason] and kill its client, unless the session has moved on from waiting for it. */ + private fun abandon( + session: Session, + launch: Launch, + reason: String, + ) { + if (!session.changes(SessionEvent.NeverConnected(launch, reason))) return + + synchronized(launchLock) { release(launch) } + } + + /** Stop expecting the hello of the launch and kill its client, if any. Must be called with [launchLock] held. */ + private fun release(launch: Launch) { + bridge.forget(launch.httpPort) + launcher.kill(launch.proxyPort) + } + + /** Build the listener that feeds the hello and the close of one launch into its session. */ private fun listener( session: Session, launch: Launch, ): BridgeListener = object : BridgeListener { + /** The id of the session that waits for the client. */ override val session: String = session.id.value + /** Connect the session to the link, unless the session has moved on from this launch. */ override fun onHello( link: BridgeLink, pid: Long, @@ -186,6 +291,7 @@ public class SessionManager internal constructor( return state is ClientState.Connected && state.link === link } + /** Stop the session when the link of its connected client closes, and release the proxy state. */ override fun onClosed(link: BridgeLink) { val before = session.client if (session.apply(SessionEvent.LinkClosed(link)) === before) return @@ -194,8 +300,14 @@ public class SessionManager internal constructor( // its process handle and session monitor for the port. synchronized(launchLock) { launcher.kill(launch.proxyPort) } } + + /** Stop the session with the reason, since the client that was refused dials only once. */ + override fun onRejected(reason: String) { + abandon(session, launch, "the client started, but its bridge plugin was rejected: $reason") + } } + /** Get the message of the innermost cause. */ private fun rootMessage(throwable: Throwable): String { val root = generateSequence(throwable) { it.cause }.last() @@ -203,10 +315,29 @@ public class SessionManager internal constructor( } public companion object { - /** How long a start waits for the in-client bridge to connect, unless the caller says otherwise. */ + /** The time a start waits for the in-client bridge to connect, unless the caller says otherwise. */ public const val DEFAULT_WAIT_MS: Long = 180_000L - // Covers a first run, where the launcher downloads the client before it handshakes. + /** + * The default of the longest wait for a launcher to complete its handshake. + * Covers a first run, where the launcher downloads the client before it handshakes. + */ private const val DEFAULT_LAUNCH_TIMEOUT_MS = 180_000L + + /** The default of the longest wait for the plugin of a launched client to say hello. */ + private const val DEFAULT_HELLO_TIMEOUT_MS = 60_000L + + /** The pause between two checks of whether the launcher of a blocked launch has exited. */ + private const val LAUNCH_POLL_MS = 100L + + /** The time a launch gets to return after its launcher has exited. */ + private const val LAUNCHER_EXIT_GRACE_MS = 1_000L + + /** The reason of a session whose client never said hello. */ + private const val NEVER_CONNECTED = "the client started, but its bridge plugin never connected" + + /** The refusal of every launch that follows one whose launcher never returned. */ + private const val HUNG_LAUNCH = + "an earlier launch never completed its handshake; restart the rsprox MCP process" } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index 8d552a5f..989f1ef7 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -2,6 +2,8 @@ package net.rsprox.mcp.bridge import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.net.InetAddress +import java.net.Socket import java.nio.file.Files import java.nio.file.attribute.PosixFilePermissions import java.util.concurrent.CompletableFuture @@ -53,31 +55,6 @@ class BridgeHubTest { return failure.cause as BridgeError } - @Test - fun `the rendezvous file names the port and token, is private, and is deleted on close`() { - val file = mapper.readTree(Files.readAllBytes(fixture.rendezvous)) - assertEquals(1, file.get("protocol").asInt()) - assertTrue(file.get("port").asInt() > 0) - assertEquals(22, file.get("token").asText().length) - assertEquals(ProcessHandle.current().pid(), file.get("pid").asLong()) - - if (Files.getFileStore(fixture.rendezvous).supportsFileAttributeView("posix")) { - assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(fixture.rendezvous))) - } - - hub.close() - assertFalse(Files.exists(fixture.rendezvous)) - } - - @Test - fun `each hub has its own token`() { - TestHub().use { other -> - val mine = mapper.readTree(Files.readAllBytes(fixture.rendezvous)).get("token") - val theirs = mapper.readTree(Files.readAllBytes(other.rendezvous)).get("token") - assertFalse(mine == theirs) - } - } - @Test fun `a hello for an expected port is welcomed and handed to its listener`() { val listener = RecordingListener(session = "s3") @@ -91,16 +68,6 @@ class BridgeHubTest { assertEquals(4242, listener.hello.get(10, TimeUnit.SECONDS).second) } - @Test - fun `a hub set to software rendering tells each client it welcomes`() { - TestHub(Rendering.SOFTWARE).use { other -> - other.hub.expect(43650, RecordingListener()) - val plugin = FakePlugin.dial(other.rendezvous, 43650, null, 1).also { plugins += it } - - assertTrue(plugin.read()!!.get("softwareRendering").asBoolean()) - } - } - @Test fun `a wrong token is rejected and leaves the expectation in place`() { val listener = RecordingListener() @@ -122,31 +89,6 @@ class BridgeHubTest { assertNull(stranger.read()) } - @Test - fun `a port is expected only once`() { - connect() - assertEquals("no session expects httpPort 43650", dial(43650).read()?.get("reject")?.asText()) - } - - @Test - fun `another protocol version is rejected as a stale jar`() { - val listener = RecordingListener() - hub.expect(43650, listener) - val reject = dial(43650, protocol = 2).read()?.get("reject")?.asText() - assertEquals("bridge protocol 2 is not supported; the installed plugin jar is stale", reject) - assertFalse(listener.hello.isDone) - } - - @Test - fun `a listener that no longer wants the client has its connection dropped`() { - val listener = RecordingListener(accept = false) - hub.expect(43650, listener) - val plugin = dial(43650) - assertEquals(1, plugin.read()?.get("welcome")?.asInt()) - assertNull(plugin.read()) - assertFalse(listener.closed.isDone) - } - @Test fun `a call sends the op and its args and returns the ok value`() { val (plugin, link) = connect() @@ -160,23 +102,6 @@ class BridgeHubTest { assertEquals(args("""{"x":380,"y":215}"""), call.get(10, TimeUnit.SECONDS)) } - @Test - fun `overlapping calls each receive their own reply, whatever the order of the answers`() { - val (plugin, link) = connect() - val slow = async { link.call("screenshot", args(), TIMEOUT_MS) } - val first = plugin.read()!! - val fast = async { link.call("state", args(), TIMEOUT_MS) } - val second = plugin.read()!! - assertEquals(listOf("screenshot", "state"), listOf(first.get("op").asText(), second.get("op").asText())) - - plugin.send("""{"id":${second.get("id")},"ok":{"gameState":"LOGIN_SCREEN"}}""") - assertEquals("LOGIN_SCREEN", fast.get(10, TimeUnit.SECONDS).get("gameState").asText()) - assertFalse(slow.isDone) - - plugin.send("""{"id":${first.get("id")},"ok":{"width":976}}""") - assertEquals(976, slow.get(10, TimeUnit.SECONDS).get("width").asInt()) - } - @Test fun `an err reply becomes a bridge error with its code and message`() { val (plugin, link) = connect() @@ -187,19 +112,6 @@ class BridgeHubTest { assertEquals("widget 558:7 is not visible", error.message) } - @Test - fun `a call that gets no reply times out and the link stays usable`() { - val (plugin, link) = connect() - val error = assertFailsWith { link.call("state", args(), timeoutMs = 50) } - assertEquals("timeout", error.code) - assertEquals("the client did not answer 'state' within 50 ms", error.message) - - val late = plugin.read()!! - plugin.send("""{"id":${late.get("id")},"ok":{}}""") - plugin.serve { _, _ -> """"ok":{"tick":7}""" } - assertEquals(7, link.call("state", args(), TIMEOUT_MS).get("tick").asInt()) - } - @Test fun `a closed connection fails the pending calls, reports the close and refuses new calls`() { val listener = RecordingListener() @@ -214,17 +126,6 @@ class BridgeHubTest { assertEquals("closed", assertFailsWith { link.call("state", args(), TIMEOUT_MS) }.code) } - @Test - fun `closing the hub drops its links and stops accepting`() { - val listener = RecordingListener() - val (plugin, link) = connect(listener) - - hub.close() - - assertNull(plugin.read()) - assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) - } - private companion object { private const val TIMEOUT_MS = 10_000L } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt deleted file mode 100644 index a848299f..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeJarTest.kt +++ /dev/null @@ -1,78 +0,0 @@ -package net.rsprox.mcp.bridge - -import net.rsprox.mcp.session.target -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.attribute.FileTime -import java.util.zip.ZipFile -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -class BridgeJarTest { - private val home: Path = Files.createTempDirectory("mcp-jar-test") - private var embedded = byteArrayOf(1, 2, 3) - private val custom = target(1, "My Server") - private val longAgo = FileTime.fromMillis(1_000_000) - - private fun jar(overrideDir: Path? = null) = BridgeJar(overrideDir, home) { embedded } - - @AfterTest - fun cleanUp() { - home.toFile().deleteRecursively() - } - - @Test - fun `a custom target gets the jar in the custom client's sideload directory`() { - val installed = jar().installFor(custom) - assertEquals(home.resolve(".rlcustom/sideloaded-plugins/rsprox-mcp-bridge.jar"), installed) - assertContentEquals(embedded, Files.readAllBytes(installed)) - } - - @Test - fun `the official target gets the jar in the official client's sideload directory`() { - val installed = jar().installFor(target(0, "Old School RuneScape")) - assertEquals(home.resolve(".runelite/sideloaded-plugins/rsprox-mcp-bridge.jar"), installed) - assertContentEquals(embedded, Files.readAllBytes(installed)) - } - - @Test - fun `an override directory is used for every target and leaves the defaults alone`() { - val dir = home.resolve("elsewhere") - assertEquals(dir.resolve("rsprox-mcp-bridge.jar"), jar(dir).installFor(custom)) - assertEquals(dir.resolve("rsprox-mcp-bridge.jar"), jar(dir).installFor(target(0, "Old School RuneScape"))) - assertEquals(listOf("elsewhere"), home.toFile().list()?.toList()) - } - - @Test - fun `an identical installed jar is left untouched`() { - val installed = jar().installFor(custom) - Files.setLastModifiedTime(installed, longAgo) - - jar().installFor(custom) - - assertEquals(longAgo, Files.getLastModifiedTime(installed)) - } - - @Test - fun `a different installed jar is replaced and nothing else is left in the directory`() { - val installed = jar().installFor(custom) - Files.setLastModifiedTime(installed, longAgo) - embedded = byteArrayOf(9, 9, 9, 9) - - jar().installFor(custom) - - assertContentEquals(embedded, Files.readAllBytes(installed)) - assertEquals(1, Files.list(installed.parent).use { it.count() }) - } - - @Test - fun `the build embeds the plugin jar`() { - val installed = BridgeJar(home.resolve("embedded")).installFor(custom) - ZipFile(installed.toFile()).use { zip -> - assertNotNull(zip.getEntry("net/rsprox/mcpbridge/McpBridgePlugin.class")) - } - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index 25ed3848..738df5cf 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -53,6 +53,7 @@ internal class RecordingListener( ) : BridgeListener { val hello = CompletableFuture>() val closed = CompletableFuture() + val rejected = CompletableFuture() override fun onHello( link: BridgeLink, @@ -66,6 +67,10 @@ internal class RecordingListener( override fun onClosed(link: BridgeLink) { closed.complete(link) } + + override fun onRejected(reason: String) { + rejected.complete(reason) + } } /** The plugin's side of the wire, driven by the test. */ diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt index e6ef478e..a2d8e70b 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt @@ -154,4 +154,21 @@ class PacketLogTest { log.add("B") assertEquals(listOf(2L), log.read(PacketQuery(after = page.next)).seqs()) } + + @Test + fun `a prot prefix keeps every prot that starts with it and combines with the other filters`() { + val log = PacketLog() + log.add("OPNPC1_V2", Origin.CLIENT) + log.add("OPNPC_FAKE", Origin.SERVER) + log.add("IF_BUTTONX", Origin.CLIENT) + log.add("XOPNPC1", Origin.CLIENT) + log.add("OPNPC6", Origin.CLIENT) + + val npcOps = setOf("OPNPC") + + assertEquals(listOf(1L, 2L, 5L), log.read(PacketQuery(protPrefixes = npcOps)).seqs()) + assertEquals(listOf(1L, 5L), log.read(PacketQuery(protPrefixes = npcOps, origin = Origin.CLIENT)).seqs()) + assertEquals(listOf(1L, 3L, 5L), log.read(PacketQuery(protPrefixes = setOf("OPNPC1", "IF_", "OPNPC6"))).seqs()) + assertEquals(listOf(5L), log.read(PacketQuery(protPrefixes = npcOps, prots = setOf("OPNPC6"))).seqs()) + } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt deleted file mode 100644 index 4213ed64..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketTapTest.kt +++ /dev/null @@ -1,230 +0,0 @@ -package net.rsprox.mcp.packets - -import net.rsprox.cache.api.Cache -import net.rsprox.cache.api.type.GameVal -import net.rsprox.cache.api.type.GameValType -import net.rsprox.cache.api.type.NpcType -import net.rsprox.cache.api.type.VarBitType -import net.rsprox.mcp.session.LoginInfo -import net.rsprox.mcp.session.Session -import net.rsprox.mcp.session.SessionId -import net.rsprox.mcp.session.target -import net.rsprox.proxy.binary.BinaryHeader -import net.rsprox.shared.SessionMonitor -import net.rsprox.shared.StreamDirection.CLIENT_TO_SERVER -import net.rsprox.shared.StreamDirection.SERVER_TO_CLIENT -import net.rsprox.shared.property.ChildProperty -import net.rsprox.shared.property.RootProperty -import net.rsprox.shared.property.com -import net.rsprox.shared.property.group -import net.rsprox.shared.property.int -import net.rsprox.shared.property.string -import kotlin.test.Test -import kotlin.test.assertEquals - -/** Drives the session monitor of one launch the way the proxy does and reads what the session shows. */ -class PacketTapTest { - private val session = Session(SessionId("s1"), target(1, "My Server")) - private val tap = PacketTap(session.packets, session.logins, TapSettingSetStore) - - private fun header(world: Int): BinaryHeader = - BinaryHeader( - headerVersion = 1, - revision = 241, - subRevision = 1, - clientType = 1, - platformType = 1, - timestamp = 0, - worldId = world, - worldFlags = 0, - worldLocation = 0, - worldHost = "127.0.0.1", - worldActivity = "", - localPlayerIndex = 1, - accountHash = ByteArray(0), - clientName = "RuneLite", - js5MasterIndex = ByteArray(0), - ) - - /** A login as the proxy reports it: a monitor bound to the recording, then the login itself. */ - private fun login(world: Int): SessionMonitor { - val header = header(world) - - return tap.forSession(header).also { it.onLogin(header) } - } - - /** A cache with no names in it, which is enough to format ids as numbers. */ - private fun SessionMonitor.withCache(): SessionMonitor { - val cache = - object : Cache { - override fun getNpcType(id: Int): NpcType? = null - - override fun listNpcTypes(): Collection = emptyList() - - override fun getVarBitType(id: Int): VarBitType? = null - - override fun listVarBitTypes(): Collection = emptyList() - - override fun getGameValType( - gameVal: GameVal, - id: Int, - ): GameValType? = null - - override fun listGameValTypes(gameVal: GameVal): Collection = emptyList() - - override fun allGameValTypes(): Map> = emptyMap() - } - - onCacheUpdate { cache } - - return this - } - - private fun packet( - prot: String, - build: RootProperty.() -> Unit = {}, - ): RootProperty { - val root = - object : RootProperty { - override val prot: String = prot - override val children: MutableList> = mutableListOf() - } - - return root.apply(build) - } - - private fun records(): List = - session.packets.read(PacketQuery()).packets.map { - "L${it.login} T${it.cycle} ${it.origin.letter} ${it.prot} ${it.text}" - } - - @Test - fun `a login is marked in the log and shows in the snapshot`() { - login(world = 302) - - assertEquals(listOf("L1 T0 P LOGIN revision=241 world=302"), records()) - assertEquals( - LoginInfo(epoch = 1, revision = 241, world = 302, name = null, online = true, transcribing = false), - session.snapshot().login, - ) - } - - @Test - fun `each packet takes its origin from the last direction the proxy announced`() { - val monitor = login(world = 302).withCache() - - monitor.onPacketDirection(CLIENT_TO_SERVER) - monitor.onTranscribe(48, packet("if_button") { com(558, 7) }) - monitor.onPacketDirection(SERVER_TO_CLIENT) - monitor.onTranscribe(49, packet("if_settext") { string("text", "McpProto is available") }) - monitor.onTranscribe(49, packet("server_tick_end")) - monitor.onPacketDirection(CLIENT_TO_SERVER) - monitor.onTranscribe(50, packet("resume_p_stringdialog") { string("string", "McpProto") }) - - assertEquals( - listOf( - "L1 T48 C IF_BUTTON [if_button] com=558:7", - "L1 T49 S IF_SETTEXT [if_settext] text=\"McpProto is available\"", - "L1 T49 S SERVER_TICK_END [server_tick_end] ", - "L1 T50 C RESUME_P_STRINGDIALOG [resume_p_stringdialog] string=\"McpProto\"", - ), - records().drop(1), - ) - } - - @Test - fun `a packet with nested properties is recorded as several lines`() { - val monitor = login(world = 302).withCache() - - monitor.onPacketDirection(SERVER_TO_CLIENT) - monitor.onTranscribe( - 7, - packet("player_info") { - group("localplayer") { - int("index", 1) - } - }, - ) - - assertEquals("L1 T7 S PLAYER_INFO [player_info] \n [localplayer] index=1", records().last()) - } - - @Test - fun `the login counts as transcribing from its first packet on`() { - val monitor = login(world = 302) - assertEquals(false, session.snapshot().login?.transcribing) - - monitor.onPacketDirection(SERVER_TO_CLIENT) - - assertEquals(true, session.snapshot().login?.transcribing) - } - - @Test - fun `a second login gets the next epoch and the first can no longer change what the session shows`() { - val first = login(world = 302) - first.onNameUpdate("First") - first.onPacketDirection(SERVER_TO_CLIENT) - - val second = login(world = 303) - second.onPacketDirection(CLIENT_TO_SERVER) - second.onTranscribe(3, packet("no_timeout")) - first.onTranscribe(90, packet("server_tick_end")) - first.onLogout(header(302)) - first.onNameUpdate("Late") - - assertEquals( - listOf( - "L1 T0 P LOGIN revision=241 world=302", - "L2 T0 P LOGIN revision=241 world=303", - "L2 T3 C NO_TIMEOUT [no_timeout] ", - "L1 T90 S SERVER_TICK_END [server_tick_end] ", - "L1 T0 P LOGOUT world=302", - ), - records(), - ) - assertEquals( - LoginInfo(epoch = 2, revision = 241, world = 303, name = null, online = true, transcribing = true), - session.snapshot().login, - ) - } - - @Test - fun `a name update and a logout show in the snapshot`() { - val monitor = login(world = 302) - - monitor.onNameUpdate("McpProto") - assertEquals("McpProto", session.snapshot().login?.name) - - monitor.onLogout(header(302)) - - assertEquals(false, session.snapshot().login?.online) - assertEquals("L1 T0 P LOGOUT world=302", records().last()) - } - - @Test - fun `a packet that cannot be formatted is recorded as such and the packets after it still arrive`() { - val monitor = login(world = 302) - - monitor.onPacketDirection(CLIENT_TO_SERVER) - monitor.onTranscribe(48, packet("if_button") { com(558, 7) }) - monitor.onTranscribe(49, packet("no_timeout")) - - assertEquals( - listOf( - "L1 T48 C IF_BUTTON [if_button] ", - "L1 T49 C NO_TIMEOUT [no_timeout] ", - ), - records().drop(1), - ) - } - - @Test - fun `the monitor of a launch records nothing before a login binds it`() { - tap.onPacketDirection(SERVER_TO_CLIENT) - tap.onTranscribe(1, packet("server_tick_end")) - tap.onNameUpdate("McpProto") - - assertEquals(emptyList(), records()) - assertEquals(null, session.snapshot().login) - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt deleted file mode 100644 index 22b9d167..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpDispatcherTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -package net.rsprox.mcp.server - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class McpDispatcherTest { - private val mapper = McpDispatcher.MAPPER - - private fun schema(json: String): ObjectNode = mapper.readTree(json) as ObjectNode - - private val echo = - Tool( - name = "echo", - description = "Returns its text argument", - inputSchema = - schema( - """{"type":"object","properties":{"text":{"type":"string"},"times":{"type":"integer","minimum":1}, - "mode":{"type":"string","enum":["a","b"]},"tags":{"type":"array","items":{"type":"string"}}}, - "required":["text"]}""", - ), - ) { args -> ToolResult.Text(args.get("text").asText()) } - - private val json = - Tool("json", "Returns a map", schema("""{"type":"object","properties":{}}""")) { - ToolResult.Json(mapOf("answer" to 42)) - } - - private val refuse = - Tool("refuse", "Always fails", schema("""{"type":"object","properties":{}}""")) { - throw ToolError("no session s9") - } - - private val crash = - Tool("crash", "Always throws", schema("""{"type":"object","properties":{}}""")) { - throw IllegalStateException("boom") - } - - private val dispatcher = McpDispatcher(listOf(echo, json, refuse, crash), "1.2.3") - - private fun post( - body: String, - origin: String? = null, - ): HttpReply = dispatcher.handle("POST", origin, body) - - private fun result(body: String): JsonNode { - val reply = post(body) - assertEquals(200, reply.status) - val node = mapper.readTree(reply.body) - assertEquals("2.0", node.get("jsonrpc").asText()) - assertNull(node.get("error"), "unexpected error: ${reply.body}") - - return node.get("result") - } - - private fun error(body: String): JsonNode = mapper.readTree(post(body).body).get("error") - - private fun callTool( - name: String, - arguments: String = "{}", - ): JsonNode = - result("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$name","arguments":$arguments}}""") - - @Test - fun `initialize echoes a supported protocol version and names the server`() { - val result = - result("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}""") - - assertEquals("2025-06-18", result.get("protocolVersion").asText()) - assertTrue(result.get("capabilities").has("tools")) - assertEquals("rsprox", result.get("serverInfo").get("name").asText()) - assertEquals("1.2.3", result.get("serverInfo").get("version").asText()) - } - - @Test - fun `initialize answers an unknown protocol version with the newest supported one`() { - val result = - result("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"1999-01-01"}}""") - - assertEquals("2025-11-25", result.get("protocolVersion").asText()) - } - - @Test - fun `the response carries the request id unchanged`() { - val reply = mapper.readTree(post("""{"jsonrpc":"2.0","id":"abc","method":"ping"}""").body) - assertEquals("abc", reply.get("id").asText()) - assertEquals(0, reply.get("result").size()) - } - - @Test - fun `tools list returns every tool with its schema`() { - val tools = result("""{"jsonrpc":"2.0","id":1,"method":"tools/list"}""").get("tools") - assertEquals(listOf("echo", "json", "refuse", "crash"), tools.map { it.get("name").asText() }) - assertEquals("Returns its text argument", tools[0].get("description").asText()) - assertEquals(echo.inputSchema, tools[0].get("inputSchema")) - } - - @Test - fun `tools call returns the tool text as one content block`() { - val result = callTool("echo", """{"text":"hello"}""") - assertFalse(result.get("isError").asBoolean()) - assertEquals(1, result.get("content").size()) - assertEquals("text", result.get("content")[0].get("type").asText()) - assertEquals("hello", result.get("content")[0].get("text").asText()) - } - - @Test - fun `a json tool result is rendered as compact json text`() { - assertEquals("""{"answer":42}""", callTool("json").get("content")[0].get("text").asText()) - } - - @Test - fun `a ToolError becomes an error result carrying its message`() { - val result = callTool("refuse") - assertTrue(result.get("isError").asBoolean()) - assertEquals("no session s9", result.get("content")[0].get("text").asText()) - } - - @Test - fun `an unexpected exception becomes an internal error result`() { - val result = callTool("crash") - assertTrue(result.get("isError").asBoolean()) - assertTrue(result.get("content")[0].get("text").asText().startsWith("internal: ")) - } - - @Test - fun `arguments that break the schema are refused before the tool runs`() { - val cases = - mapOf( - """{}""" to "missing required argument 'text'", - """{"text":5}""" to "'text' must be of type string", - """{"text":"a","txt":"b"}""" to "unknown argument 'txt'", - """{"text":"a","times":0}""" to "'times' must be at least 1", - """{"text":"a","times":1.5}""" to "'times' must be of type integer", - """{"text":"a","mode":"c"}""" to "'mode' must be one of a, b", - """{"text":"a","tags":["x",1]}""" to "every item of 'tags' must be of type string", - ) - - for ((arguments, expected) in cases) { - val result = callTool("echo", arguments) - assertTrue(result.get("isError").asBoolean(), arguments) - val text = result.get("content")[0].get("text").asText() - assertTrue(text.contains(expected), "$arguments gave: $text") - } - } - - @Test - fun `tools call without arguments runs the tool with an empty object`() { - val result = result("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"json"}}""") - assertFalse(result.get("isError").asBoolean()) - } - - @Test - fun `an unknown tool is an invalid params error`() { - val error = error("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nope"}}""") - assertEquals(-32602, error.get("code").asInt()) - } - - @Test - fun `an unknown method is a method not found error`() { - val reply = post("""{"jsonrpc":"2.0","id":7,"method":"resources/list"}""") - assertEquals(200, reply.status) - val node = mapper.readTree(reply.body) - assertEquals(7, node.get("id").asInt()) - assertEquals(-32601, node.get("error").get("code").asInt()) - assertNull(node.get("result")) - } - - @Test - fun `a notification is accepted with no body`() { - val reply = post("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") - assertEquals(202, reply.status) - assertNull(reply.body) - } - - @Test - fun `a non-local origin is forbidden and a local one is served`() { - val ping = """{"jsonrpc":"2.0","id":1,"method":"ping"}""" - assertEquals(403, post(ping, origin = "https://example.com").status) - assertEquals(403, post(ping, origin = "null").status) - assertEquals(200, post(ping, origin = "http://localhost:6274").status) - assertEquals(200, post(ping, origin = "http://127.0.0.1:6274").status) - } - - @Test - fun `a batch or malformed body is rejected`() { - val batch = post("""[{"jsonrpc":"2.0","id":1,"method":"ping"}]""") - assertEquals(400, batch.status) - assertEquals(-32600, mapper.readTree(batch.body).get("error").get("code").asInt()) - val malformed = post("{not json") - assertEquals(400, malformed.status) - assertEquals(-32700, mapper.readTree(malformed.body).get("error").get("code").asInt()) - } - - @Test - fun `only POST is allowed`() { - assertEquals(405, dispatcher.handle("GET", null, "").status) - assertEquals(405, dispatcher.handle("DELETE", null, "").status) - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt deleted file mode 100644 index e577d2bd..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/MinimalArguments.kt +++ /dev/null @@ -1,23 +0,0 @@ -package net.rsprox.mcp.server - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode - -/** The smallest arguments [tool] accepts: a value of the right type for each required property. */ -internal fun minimalArguments(tool: Tool): ObjectNode { - val arguments = McpDispatcher.MAPPER.createObjectNode() - - for (required in tool.inputSchema.get("required") ?: emptyList()) { - val property = tool.inputSchema.get("properties").get(required.asText()) - - when (val type = property.get("type").asText()) { - "string" -> arguments.put(required.asText(), property.get("enum")?.get(0)?.asText() ?: "x") - "integer" -> arguments.put(required.asText(), property.get("minimum")?.asLong() ?: 0) - "boolean" -> arguments.put(required.asText(), false) - "array" -> arguments.putArray(required.asText()) - else -> error("${tool.name} requires '${required.asText()}' of type $type, which this helper cannot make") - } - } - - return arguments -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt deleted file mode 100644 index d9324433..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolsTest.kt +++ /dev/null @@ -1,350 +0,0 @@ -package net.rsprox.mcp.server - -import com.fasterxml.jackson.databind.JsonNode -import net.rsprox.mcp.bridge.FakePlugin -import net.rsprox.mcp.bridge.TestHub -import net.rsprox.mcp.packets.Origin -import net.rsprox.mcp.packets.TapSettingSetStore -import net.rsprox.mcp.session.FakeLauncher -import net.rsprox.mcp.session.SessionManager -import java.util.concurrent.CopyOnWriteArrayList -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ToolsTest { - private val mapper = McpDispatcher.MAPPER - private val fixture = TestHub() - private val manager = SessionManager(FakeLauncher(), TapSettingSetStore, fixture.hub) - private val dispatcher = McpDispatcher(tools { manager }, "test") - - private val plugins = ArrayList() - - @AfterTest - fun cleanUp() { - plugins.forEach { it.close() } - fixture.close() - } - - /** Session s1 with a connected client that answers every op through [answer]. */ - private fun connect(answer: (op: String, args: JsonNode) -> String) { - manager.start(null, null, 0) - val plugin = FakePlugin.dial(fixture.rendezvous, 43650).also { plugins += it } - plugin.read() - assertEquals("connected", manager.start(null, "s1", 10_000).state) - plugin.serve(answer) - } - - private fun call( - name: String, - arguments: String = "{}", - ): JsonNode { - val body = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$name","arguments":$arguments}}""" - - return mapper.readTree(dispatcher.handle("POST", null, body).body).get("result") - } - - private fun text( - name: String, - arguments: String = "{}", - ): String { - val result = call(name, arguments) - assertFalse(result.get("isError").asBoolean(), result.toString()) - - return result.get("content")[0].get("text").asText() - } - - @Test - fun `every tool is listed with a description and an object schema`() { - val body = dispatcher.handle("POST", null, """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""").body - val listed = mapper.readTree(body).get("result").get("tools") - assertTrue(listed.size() > 0) - - for (tool in listed) { - val name = tool.get("name").asText() - assertTrue(tool.get("description").asText().isNotBlank(), name) - assertEquals("object", tool.get("inputSchema").get("type").asText(), name) - assertTrue(tool.get("inputSchema").get("properties").isObject, name) - } - } - - @Test - fun `every client tool calls the op its name ends in, strips the session and adds the cursor from before`() { - val forwarded = CopyOnWriteArrayList>() - connect { op, args -> - forwarded += op to args - - // What the action causes arrives while the call is in flight. - manager.resolve("s1").packets.append(1, 5, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"hello\"") - """"ok":{}""" - } - - val log = manager.resolve("s1").packets - val clientTools = tools { manager }.filter { it.name.startsWith("client_") } - assertTrue(clientTools.isNotEmpty()) - - for (tool in clientTools) { - forwarded.clear() - val before = log.head().seq - val arguments = minimalArguments(tool) - - val result = call(tool.name, arguments.deepCopy().put("session", "s1").toString()) - - assertFalse(result.get("isError").asBoolean(), "${tool.name}: $result") - val (op, sent) = forwarded.single() - assertEquals(tool.name.removePrefix("client_"), op) - assertFalse(sent.has("session"), tool.name) - arguments.fields().forEach { (name, value) -> assertEquals(value, sent.get(name), "${tool.name} $name") } - val answer = mapper.readTree(result.get("content").last().get("text").asText()) - assertEquals(before, answer.get("cursor").asLong(), tool.name) - assertEquals(before + 1, log.head().seq, tool.name) - } - } - - @Test - fun `session start, list and stop report the session as compact json`() { - assertEquals( - """{"session":"s1","target":"My Server","state":"launching","generation":1,"proxyPort":43751,""" + - """"httpPort":43650,"cursor":1}""", - text("session_start", """{"target":"My Server","wait_ms":0}"""), - ) - val list = mapper.readTree(text("session_list")) - assertEquals("s1", list.get("sessions").single().get("session").asText()) - assertEquals(listOf("Old School RuneScape", "My Server"), list.get("targets").map { it.asText() }) - - val stopped = mapper.readTree(text("session_stop")) - assertEquals("stopped", stopped.get("state").asText()) - assertEquals("stopped by caller", stopped.get("reason").asText()) - } - - @Test - fun `packets read prints a meta line and one line per packet`() { - text("session_start", """{"wait_ms":0}""") - val log = manager.resolve("s1").packets - log.append(1, 48, Origin.CLIENT, "RESUME_P_STRINGDIALOG", "[resume_p_stringdialog] string=\"McpProto\"") - log.append(1, 49, Origin.SERVER, "PLAYER_INFO", "[player_info]\n [localplayer] index=1\n - move") - - assertEquals( - """ - {"next":3,"head":3,"dropped":0,"timedOut":false,"count":3} - 1 L0 T0 P CLIENT_LAUNCHED generation=1 proxyPort=43751 httpPort=43650 - 2 L1 T48 C RESUME_P_STRINGDIALOG [resume_p_stringdialog] string="McpProto" - 3 L1 T49 S PLAYER_INFO [player_info] - [localplayer] index=1 - - move - """.trimIndent(), - text("packets_read"), - ) - } - - @Test - fun `packets read applies its filters and cursor`() { - text("session_start", """{"wait_ms":0}""") - val log = manager.resolve("s1").packets - log.append(1, 1, Origin.CLIENT, "IF_BUTTON", "[if_button] com=558:7") - log.append(1, 2, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"McpProto is available\"") - - val byProt = text("packets_read", """{"prots":["if_settext"]}""").lines() - assertEquals("""{"next":3,"head":3,"dropped":0,"timedOut":false,"count":1}""", byProt[0]) - assertTrue(byProt[1].startsWith("3 L1 T2 S IF_SETTEXT ")) - - assertEquals(2, text("packets_read", """{"origin":"client"}""").lines().size) - assertEquals(2, text("packets_read", """{"origin":"proxy","contains":"generation=1"}""").lines().size) - assertEquals(2, text("packets_read", """{"after":2}""").lines().size) - assertEquals(3, text("packets_read", """{"limit":2}""").lines().size) - - val waited = text("packets_read", """{"after":3,"wait_ms":20}""") - assertEquals("""{"next":3,"head":3,"dropped":0,"timedOut":true,"count":0}""", waited) - } - - @Test - fun `a tool call before any session exists tells the caller what to do`() { - val result = call("packets_read") - assertTrue(result.get("isError").asBoolean()) - assertEquals("no session exists yet; call session_start first", result.get("content")[0].get("text").asText()) - } - - @Test - fun `an origin outside the allowed set is refused`() { - text("session_start", """{"wait_ms":0}""") - assertTrue(call("packets_read", """{"origin":"sideways"}""").get("isError").asBoolean()) - } - - @Test - fun `a client tool returns the plugin's answer with the cursor taken before the call`() { - val forwarded = ArrayList>() - connect { op, args -> - forwarded += op to args - - // What the action causes arrives while the call is in flight. - manager.resolve("s1").packets.append(1, 5, Origin.SERVER, "IF_SETTEXT", "[if_settext] text=\"hello\"") - """"ok":{"gameState":"LOGIN_SCREEN","tick":0}""" - } - - val log = manager.resolve("s1").packets - val before = log.head().seq - - val result = text("client_state", """{"session":"s1"}""") - - assertEquals("""{"gameState":"LOGIN_SCREEN","tick":0,"cursor":$before}""", result) - assertEquals(before + 1, log.head().seq) - assertEquals(listOf("state" to mapper.readTree("{}")), forwarded) - } - - @Test - fun `a client tool forwards its arguments without the session`() { - val forwarded = ArrayList>() - connect { op, args -> - forwarded += op to args - """"ok":{"roots":[548],"widgets":[],"truncated":false}""" - } - - text("client_widgets", """{"session":"s1","group":558,"text":"name","hidden":true,"limit":5}""") - text("client_vars", """{"varps":[1055],"varbits":[8119]}""") - - assertEquals( - listOf( - "widgets" to mapper.readTree("""{"group":558,"text":"name","hidden":true,"limit":5}"""), - "vars" to mapper.readTree("""{"varps":[1055],"varbits":[8119]}"""), - ), - forwarded, - ) - } - - @Test - fun `a widget listing without a limit forwards the default one`() { - val forwarded = ArrayList() - connect { _, args -> - forwarded.add(args) - """"ok":{"roots":[548],"widgets":[],"truncated":false}""" - } - - text("client_widgets", """{"group":558}""") - - assertEquals(listOf(mapper.readTree("""{"group":558,"limit":200}""")), forwarded) - } - - @Test - fun `a screenshot is an image block followed by its size and the cursor`() { - connect { _, _ -> """"ok":{"png":"iVBORw0KGgo=","width":976,"height":558}""" } - val cursor = manager.resolve("s1").packets.head().seq - - val content = call("client_screenshot").get("content") - - assertEquals( - mapper.readTree( - """[{"type":"image","data":"iVBORw0KGgo=","mimeType":"image/png"}, - {"type":"text","text":"{\"width\":976,\"height\":558,\"cursor\":$cursor}"}]""", - ), - content, - ) - } - - @Test - fun `a failure reported by the client becomes a tool error with its code`() { - connect { _, _ -> """"err":{"code":"not_found","message":"varbit 99999 could not be read"}""" } - - val result = call("client_vars", """{"varbits":[99999]}""") - - assertTrue(result.get("isError").asBoolean()) - assertEquals("not_found: varbit 99999 could not be read", result.get("content")[0].get("text").asText()) - } - - @Test - fun `a client tool on a session without a connected client says how to get one`() { - text("session_start", """{"wait_ms":0}""") - val launching = call("client_state") - assertTrue(launching.get("isError").asBoolean()) - assertEquals( - "session s1 is still launching; call session_start with this session to wait for it", - launching.get("content")[0].get("text").asText(), - ) - - text("session_stop") - assertEquals( - "session s1 has no connected client: stopped by caller", - call("client_state").get("content")[0].get("text").asText(), - ) - } - - @Test - fun `a client tool refuses arguments of the wrong type before calling the client`() { - val calls = ArrayList() - connect { op, _ -> - calls += op - """"ok":{}""" - } - - assertTrue(call("client_vars", """{"varps":["1055"]}""").get("isError").asBoolean()) - assertTrue(call("client_widgets", """{"hidden":"yes"}""").get("isError").asBoolean()) - assertEquals(emptyList(), calls) - } - - @Test - fun `the input tools forward their arguments and return the client's answer`() { - val forwarded = ArrayList>() - connect { op, args -> - forwarded += op to args - - when (op) { - "login" -> """"ok":{"gameState":"LOGGED_IN"}""" - "click" -> """"ok":{"x":380,"y":215}""" - else -> """"ok":{"typed":8}""" - } - } - - val cursor = manager.resolve("s1").packets.head().seq - - assertEquals( - """{"gameState":"LOGGED_IN","cursor":$cursor}""", - text("client_login", """{"username":"mcp","password":"secret","wait_ms":2000}"""), - ) - assertEquals("""{"x":380,"y":215,"cursor":$cursor}""", text("client_click", """{"widget":"558:7"}""")) - text("client_click", """{"session":"s1","x":380,"y":215,"button":"right"}""") - assertEquals("""{"typed":8,"cursor":$cursor}""", text("client_type", """{"text":"McpProto","enter":true}""")) - - assertEquals( - listOf( - "login" to mapper.readTree("""{"username":"mcp","password":"secret","wait_ms":2000}"""), - "click" to mapper.readTree("""{"widget":"558:7"}"""), - "click" to mapper.readTree("""{"x":380,"y":215,"button":"right"}"""), - "type" to mapper.readTree("""{"text":"McpProto","enter":true}"""), - ), - forwarded, - ) - } - - @Test - fun `a login needs a username and a password`() { - val calls = ArrayList() - connect { _, args -> - calls.add(args) - """"ok":{"gameState":"LOGGED_IN"}""" - } - - val missing = call("client_login", """{"password":"secret"}""") - assertTrue(missing.get("isError").asBoolean()) - assertEquals( - "client_login: missing required argument 'username'", - missing.get("content")[0].get("text").asText(), - ) - - val noPassword = call("client_login", """{"username":"mcp"}""") - assertTrue(noPassword.get("isError").asBoolean()) - assertEquals( - "client_login: missing required argument 'password'", - noPassword.get("content")[0].get("text").asText(), - ) - - text("client_login", """{"username":"mcp","password":"secret"}""") - assertEquals(listOf(mapper.readTree("""{"username":"mcp","password":"secret","wait_ms":15000}""")), calls) - } - - @Test - fun `a button other than left or right is refused`() { - connect { _, _ -> """"ok":{}""" } - assertTrue(call("client_click", """{"x":1,"y":1,"button":"middle"}""").get("isError").asBoolean()) - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt deleted file mode 100644 index 2c8784b4..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/ProxyServiceLauncherTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package net.rsprox.mcp.session - -import java.net.ServerSocket -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ProxyServiceLauncherTest { - @Test - fun `a proxy port must never be handed out while it or its http port is taken`() { - val allocated = generateSequence(100) { it + 1 }.iterator() - val taken = setOf(100, 1101) - - val port = firstFreePort(allocated::next, { it + 1000 }, { it !in taken }) - - assertEquals(102, port) - assertEquals(103, allocated.next()) - } - - @Test - fun `a port that another socket holds cannot be bound and a released one can`() { - val holder = ServerSocket(0) - val port = holder.localPort - - holder.use { assertFalse(canBind(port)) } - - assertTrue(canBind(port)) - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt new file mode 100644 index 00000000..610f7245 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt @@ -0,0 +1,52 @@ +package net.rsprox.mcp.session + +import net.rsprox.proxy.target.ProxyTargetConfig +import java.util.concurrent.CopyOnWriteArrayList + +internal fun target( + id: Int, + name: String, +): ProxyTargetConfig = + ProxyTargetConfig( + id = id, + name = name, + javConfigUrl = "http://127.0.0.1/jav_config.ws", + modulus = null, + varpCount = 5000, + revision = null, + runeliteBootstrapUrl = null, + runeliteBootstrapCommitHash = null, + runeliteGamepackUrl = null, + binaryFolder = null, + ) + +internal class FakeLauncher( + private val targets: List = listOf(target(0, "Old School RuneScape"), target(1, "My Server")), +) : ClientLauncher { + val reserved = ArrayList() + + // A client that goes away is killed from the bridge's reader thread. + val killed = CopyOnWriteArrayList() + var reserve: (ProxyTargetConfig) -> Unit = {} + var launch: () -> Unit = {} + var launcherExited: () -> Boolean = { false } + + override fun targets(): List = targets + + override fun reserve(target: ProxyTargetConfig): Reservation { + reserve.invoke(target) + reserved += target + val earlier = reserved.size - 1 + + return Reservation(FIRST_PROXY_PORT + earlier, FIRST_HTTP_PORT + earlier, { launch() }, { launcherExited() }) + } + + override fun kill(proxyPort: Int) { + killed += proxyPort + } + + companion object { + const val FIRST_PROXY_PORT = 43751 + const val FIRST_HTTP_PORT = 43650 + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt new file mode 100644 index 00000000..d4fa35bc --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt @@ -0,0 +1,193 @@ +package net.rsprox.mcp.session + +import net.rsprox.mcp.bridge.FakePlugin +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.bridge.awaitTrue +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.server.McpDispatcher +import net.rsprox.mcp.server.ToolError +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class SessionManagerRecoveryTest { + private val launcher = FakeLauncher() + private val fixture = TestHub() + private val plugins = ArrayList() + + @AfterTest + fun cleanUp() { + plugins.forEach { it.close() } + fixture.close() + } + + private fun manager( + launchTimeoutMs: Long = 60_000, + helloTimeoutMs: Long = 60_000, + ) = SessionManager(launcher, TapSettingSetStore, fixture.hub, launchTimeoutMs, helloTimeoutMs) + + private fun dial( + httpPort: Int, + token: String? = null, + protocol: Int = 1, + ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, token, protocol).also { plugins += it } + + private fun SessionManager.awaitStopped(session: String): SessionSnapshot { + awaitTrue("session $session is stopped") { list().single { it.session == session }.state == "stopped" } + + return list().single { it.session == session } + } + + private fun SessionManager.markers(session: String): List = + resolve(session).packets.read(PacketQuery()).packets.map { it.prot } + + private fun rejection(httpPort: Int): String? = dial(httpPort).read()?.get("reject")?.asText() + + @Test + fun `a client whose plugin never says hello is killed and its session stops with the reason`() { + val manager = manager(helloTimeoutMs = 100) + + val waited = manager.start(null, null, 10_000) + + assertEquals("stopped", waited.state) + assertEquals("the client started, but its bridge plugin never connected", waited.reason) + awaitTrue("the client is killed") { launcher.killed == listOf(43751) } + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), manager.markers("s1")) + assertEquals("no session expects httpPort 43650", rejection(43650)) + assertEquals(2, manager.start(null, "s1", 0).generation) + } + + @Test + fun `a client whose plugin says hello in time stays connected after the deadline`() { + val manager = manager(helloTimeoutMs = 1_000) + manager.start(null, null, 0) + dial(FakeLauncher.FIRST_HTTP_PORT) + assertEquals("connected", manager.start(null, "s1", 10_000).state) + + // The deadlines pass in the order of the launches, so that of s1 has passed once s2 is stopped. + manager.start(null, null, 0) + manager.awaitStopped("s2") + + awaitTrue("only the client of s2 is killed") { launcher.killed == listOf(43752) } + assertEquals("connected", manager.list().first().state) + } + + @Test + fun `a hello with a bad token is rejected and leaves the launching session waiting for its client`() { + val manager = manager() + manager.start(null, null, 0) + + assertEquals("bad token", dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) + + assertEquals("launching", manager.list().single().state) + assertEquals(emptyList(), launcher.killed) + + dial(43650) + + assertEquals("connected", manager.start(null, "s1", 10_000).state) + } + + @Test + fun `a hello of a stale plugin jar stops the session and says the jar is stale`() { + val manager = manager() + manager.start(null, null, 0) + + dial(43650, protocol = 2).read() + + assertEquals( + "the client started, but its bridge plugin was rejected: " + + "bridge protocol 2 is not supported; the installed plugin jar is stale", + manager.awaitStopped("s1").reason, + ) + } + + @Test + fun `a rejected hello that names the port of a connected session leaves that session alone`() { + val manager = manager() + manager.start(null, null, 0) + dial(43650) + assertEquals("connected", manager.start(null, "s1", 10_000).state) + + assertEquals("bad token", dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) + assertEquals("no session expects httpPort 43650", rejection(43650)) + + assertEquals("connected", manager.list().single().state) + assertEquals(emptyList(), launcher.killed) + } + + @Test + fun `a hello that arrives after its session was stopped is rejected`() { + val manager = manager() + manager.start(null, null, 0) + manager.stop(null) + + assertEquals("no session expects httpPort 43650", rejection(43650)) + assertEquals("stopped by caller", manager.list().single().reason) + } + + @Test + fun `the hub expects no hello from any of several launches that failed`() { + val manager = manager() + launcher.launch = { throw IllegalStateException("proxy port could not be bound") } + + assertFailsWith { manager.start(null, null, 0) } + repeat(2) { assertFailsWith { manager.start(null, "s1", 0) } } + + assertEquals(3, launcher.reserved.size) + + for (httpPort in 43650..43652) { + assertEquals("no session expects httpPort $httpPort", rejection(httpPort)) + } + + assertEquals("proxy port could not be bound", manager.list().single().reason) + } + + @Test + fun `a launch whose launcher exits fails at once and leaves the other sessions usable`() { + val manager = manager() + manager.start(null, null, 0) + val plugin = dial(43650) + plugin.read() + assertEquals("connected", manager.start(null, "s1", 10_000).state) + plugin.serve { _, _ -> """"ok":{"tick":7}""" } + + val release = CountDownLatch(1) + launcher.launch = { release.await() } + launcher.launcherExited = { true } + + try { + val launch = CompletableFuture.supplyAsync { assertFailsWith { manager.start(null, null, 0) } } + + // The launch timeout is a minute; a launcher that is gone must not be waited for that long. + val error = launch.get(20, TimeUnit.SECONDS) + assertEquals( + "session s2 failed to launch: the launcher exited before completing its handshake", + error.message, + ) + + val stopped = manager.list().last() + assertEquals("stopped", stopped.state) + assertEquals("the launcher exited before completing its handshake", stopped.reason) + assertEquals(listOf(43752), launcher.killed) + assertEquals("no session expects httpPort 43651", rejection(43651)) + + val refused = assertFailsWith { manager.start(null, "s2", 0) } + assertTrue(refused.message!!.contains("restart the rsprox MCP process"), refused.message) + + assertEquals(listOf("connected", "stopped"), manager.list().map { it.state }) + assertEquals("connected", manager.start(null, "s1", 0).state) + val link = manager.resolve("s1").requireLink() + assertEquals(7, link.call("state", McpDispatcher.MAPPER.createObjectNode(), 10_000).get("tick").asInt()) + assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), manager.markers("s2")) + assertEquals("stopped by caller", manager.stop("s1").reason) + } finally { + release.countDown() + } + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt deleted file mode 100644 index d6c1c045..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerTest.kt +++ /dev/null @@ -1,301 +0,0 @@ -package net.rsprox.mcp.session - -import net.rsprox.mcp.bridge.FakePlugin -import net.rsprox.mcp.bridge.TestHub -import net.rsprox.mcp.bridge.awaitTrue -import net.rsprox.mcp.packets.PacketQuery -import net.rsprox.mcp.packets.TapSettingSetStore -import net.rsprox.mcp.server.ToolError -import net.rsprox.proxy.target.ProxyTargetConfig -import java.util.concurrent.CompletableFuture -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull -import kotlin.test.assertTrue - -internal class FakeLauncher( - private val targets: List = listOf(target(0, "Old School RuneScape"), target(1, "My Server")), -) : ClientLauncher { - val reserved = ArrayList() - - // A client that goes away is killed from the bridge's reader thread. - val killed = CopyOnWriteArrayList() - var reserve: (ProxyTargetConfig) -> Unit = {} - var launch: () -> Unit = {} - - override fun targets(): List = targets - - override fun reserve(target: ProxyTargetConfig): Reservation { - reserve.invoke(target) - reserved += target - - return Reservation(43750 + reserved.size, 43649 + reserved.size) { launch() } - } - - override fun kill(proxyPort: Int) { - killed += proxyPort - } -} - -class SessionManagerTest { - private val launcher = FakeLauncher() - private val fixture = TestHub() - private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub, launchTimeoutMs = 200) - private val plugins = ArrayList() - - @AfterTest - fun cleanUp() { - plugins.forEach { it.close() } - fixture.close() - } - - private fun dial( - httpPort: Int, - pid: Long = 4242, - ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, pid = pid).also { plugins += it } - - /** A session whose client has said hello, and that client. */ - private fun connect(): FakePlugin { - manager.start(null, null, 0) - val plugin = dial(43650) - assertEquals("s1", plugin.read()?.get("session")?.asText()) - assertEquals("connected", manager.start(null, "s1", 10_000).state) - - return plugin - } - - private fun markers(): List = - manager - .resolve("s1") - .packets - .read(PacketQuery()) - .packets - .map { it.prot } - - @Test - fun `a first start launches the first custom target and stays launching`() { - val snapshot = manager.start(null, null, 0) - assertEquals("s1", snapshot.session) - assertEquals("My Server", snapshot.target) - assertEquals("launching", snapshot.state) - assertEquals(1, snapshot.generation) - assertEquals(43751, snapshot.proxyPort) - assertEquals(43650, snapshot.httpPort) - assertEquals(listOf(snapshot), manager.list()) - } - - @Test - fun `a target is chosen by name regardless of case`() { - assertEquals("Old School RuneScape", manager.start("old school runescape", null, 0).target) - } - - @Test - fun `an unknown target names the known ones and creates no session`() { - val error = assertFailsWith { manager.start("Nowhere", null, 0) } - assertEquals("no target 'Nowhere'. Targets: Old School RuneScape, My Server", error.message) - assertEquals(emptyList(), manager.list()) - } - - @Test - fun `starting a running session again launches nothing`() { - manager.start(null, null, 0) - val again = manager.start(null, "s1", 0) - assertEquals(1, again.generation) - assertEquals(1, launcher.reserved.size) - } - - @Test - fun `stop kills the client and a later start relaunches the same session on new ports`() { - manager.start(null, null, 0) - val stopped = manager.stop("s1") - assertEquals("stopped", stopped.state) - assertEquals("stopped by caller", stopped.reason) - assertEquals(listOf(43751), launcher.killed) - - val relaunched = manager.start(null, "s1", 0) - assertEquals("s1", relaunched.session) - assertEquals("launching", relaunched.state) - assertEquals(2, relaunched.generation) - assertEquals(43752, relaunched.proxyPort) - assertEquals( - listOf("CLIENT_LAUNCHED", "CLIENT_EXITED", "CLIENT_LAUNCHED"), - manager.resolve("s1").packets.read(PacketQuery()).packets.map { it.prot }, - ) - } - - @Test - fun `stopping a stopped session changes nothing`() { - manager.start(null, null, 0) - val first = manager.stop(null) - val second = manager.stop(null) - assertEquals(first, second) - assertEquals(listOf(43751), launcher.killed) - } - - @Test - fun `a session cannot be restarted on another target`() { - manager.start("My Server", null, 0) - val error = assertFailsWith { manager.start("Old School RuneScape", "s1", 0) } - assertEquals("session s1 belongs to target 'My Server'", error.message) - } - - @Test - fun `a target that cannot be prepared fails without creating a session`() { - launcher.reserve = { throw RuntimeException("wrapper", IllegalStateException("connection refused")) } - val error = assertFailsWith { manager.start(null, null, 0) } - assertEquals("could not prepare target 'My Server': connection refused", error.message) - assertEquals(emptyList(), manager.list()) - } - - @Test - fun `a launch that fails stops the session with the cause and can be retried`() { - launcher.launch = { throw IllegalStateException("proxy port 43751 could not be bound") } - val error = assertFailsWith { manager.start(null, null, 0) } - assertEquals("session s1 failed to launch: proxy port 43751 could not be bound", error.message) - val stopped = manager.list().single() - assertEquals("stopped", stopped.state) - assertEquals("proxy port 43751 could not be bound", stopped.reason) - assertEquals(listOf(43751), launcher.killed) - - launcher.launch = {} - val retried = manager.start(null, "s1", 0) - assertEquals("launching", retried.state) - assertEquals(2, retried.generation) - } - - @Test - fun `a launch that never returns is abandoned and blocks further launches`() { - val release = CountDownLatch(1) - launcher.launch = { release.await() } - - try { - val error = assertFailsWith { manager.start(null, null, 0) } - assertEquals("session s1 failed to launch: launcher never completed its handshake", error.message) - assertEquals("stopped", manager.list().single().state) - assertEquals(listOf(43751), launcher.killed) - - launcher.launch = {} - - for (session in listOf("s1", null)) { - val refused = assertFailsWith { manager.start(null, session, 0) } - assertTrue(refused.message!!.contains("restart the rsprox MCP process"), refused.message) - } - - assertEquals(1, launcher.reserved.size) - } finally { - release.countDown() - } - } - - @Test - fun `start returns connected, with the client pid, once the plugin says hello`() { - launcher.launch = { dial(43650, pid = 7) } - - val snapshot = manager.start(null, null, 10_000) - - assertEquals("connected", snapshot.state) - assertEquals(7, snapshot.pid) - assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED"), markers()) - } - - @Test - fun `a start whose wait elapsed is extended by starting the session again`() { - assertEquals("launching", manager.start(null, null, 30).state) - val waiting = CompletableFuture.supplyAsync { manager.start(null, "s1", 10_000) } - - dial(43650) - - assertEquals("connected", waiting.get(10, TimeUnit.SECONDS).state) - assertEquals(1, launcher.reserved.size) - } - - @Test - fun `a client that goes away stops the session and releases its proxy port`() { - val plugin = connect() - - plugin.close() - - awaitTrue("the session is stopped") { manager.list().single().state == "stopped" } - assertEquals("client exited", manager.list().single().reason) - awaitTrue("the proxy port is released") { launcher.killed == listOf(43751) } - assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED", "CLIENT_EXITED"), markers()) - - assertEquals(2, manager.start(null, "s1", 0).generation) - } - - @Test - fun `stopping a connected session drops its client and keeps the caller's reason`() { - val plugin = connect() - - val stopped = manager.stop(null) - - assertEquals("stopped by caller", stopped.reason) - assertNull(plugin.read()) - assertEquals("stopped by caller", manager.list().single().reason) - assertEquals(listOf(43751), launcher.killed) - assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED", "CLIENT_EXITED"), markers()) - } - - @Test - fun `a client of an earlier launch does not connect the relaunched session`() { - manager.start(null, null, 0) - manager.stop(null) - manager.start(null, "s1", 0) - - val late = dial(43650) - late.read() - assertNull(late.read()) - assertEquals("launching", manager.list().single().state) - - dial(43651, pid = 9) - val relaunched = manager.start(null, "s1", 10_000) - assertEquals("connected", relaunched.state) - assertEquals(9, relaunched.pid) - assertEquals(2, relaunched.generation) - } - - @Test - fun `a client that says hello after its session was stopped is dropped`() { - manager.start(null, null, 0) - manager.stop(null) - - val late = dial(43650) - - assertEquals("s1", late.read()?.get("session")?.asText()) - assertNull(late.read()) - assertEquals("stopped by caller", manager.list().single().reason) - assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), markers()) - } - - @Test - fun `starting a connected session again launches nothing and keeps its client`() { - connect() - - val again = manager.start(null, "s1", 0) - - assertEquals("connected", again.state) - assertEquals(1, again.generation) - assertEquals(1, launcher.reserved.size) - assertEquals(emptyList(), launcher.killed) - } - - @Test - fun `an omitted session resolves only when exactly one exists`() { - val none = assertFailsWith { manager.resolve(null) } - assertEquals("no session exists yet; call session_start first", none.message) - - manager.start(null, null, 0) - assertEquals("s1", manager.resolve(null).id.value) - - manager.start(null, null, 0) - val several = assertFailsWith { manager.resolve(null) } - assertEquals("several sessions exist; pass one of: s1, s2", several.message) - val unknown = assertFailsWith { manager.resolve("s9") } - assertEquals("no session 's9'. Sessions: s1, s2", unknown.message) - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt deleted file mode 100644 index 4bfc548a..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionTest.kt +++ /dev/null @@ -1,154 +0,0 @@ -package net.rsprox.mcp.session - -import net.rsprox.mcp.bridge.idleLink -import net.rsprox.mcp.packets.PacketQuery -import net.rsprox.mcp.server.ToolError -import net.rsprox.mcp.session.ClientState.Connected -import net.rsprox.mcp.session.ClientState.Launching -import net.rsprox.mcp.session.SessionEvent.Hello -import net.rsprox.mcp.session.SessionEvent.Launched -import net.rsprox.mcp.session.SessionEvent.LinkClosed -import net.rsprox.mcp.session.SessionEvent.Stop -import net.rsprox.proxy.target.ProxyTargetConfig -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull -import kotlin.test.assertSame - -internal fun target( - id: Int, - name: String, -): ProxyTargetConfig = - ProxyTargetConfig( - id = id, - name = name, - javConfigUrl = "http://127.0.0.1/jav_config.ws", - modulus = null, - varpCount = 5000, - revision = null, - runeliteBootstrapUrl = null, - runeliteBootstrapCommitHash = null, - runeliteGamepackUrl = null, - binaryFolder = null, - ) - -class SessionTest { - private val first = Launch(generation = 1, proxyPort = 43751, httpPort = 43650, startedAtMs = 0) - private val second = Launch(generation = 2, proxyPort = 43752, httpPort = 43651, startedAtMs = 0) - private val link = idleLink() - - @Test - fun `a close from the link of an earlier launch must never stop the relaunched session`() { - val relaunched = Connected(second, idleLink(), pid = 9) - - assertSame(relaunched, reduce(relaunched, LinkClosed(link))) - } - - @Test - fun `each transition leaves one marker in the packet log and dropped events leave none`() { - val session = Session(SessionId("s1"), target(1, "My Server")) - session.apply(Launched(first)) - session.apply(Launched(second)) - session.apply(Hello(first.httpPort, link, pid = 7)) - session.apply(Stop("stopped by caller")) - session.apply(Stop("again")) - - val markers = session.packets.read(PacketQuery()).packets - assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED", "CLIENT_EXITED"), markers.map { it.prot }) - assertEquals("generation=1 proxyPort=43751 httpPort=43650", markers[0].text) - assertEquals("pid=7", markers[1].text) - assertEquals("stopped by caller", markers[2].text) - } - - @Test - fun `the snapshot describes the current state`() { - val session = Session(SessionId("s1"), target(1, "My Server")) - val stopped = session.snapshot() - assertEquals("stopped", stopped.state) - assertEquals("not started", stopped.reason) - assertNull(stopped.proxyPort) - assertEquals(0, stopped.cursor) - - session.apply(Launched(first)) - val launching = session.snapshot() - assertEquals("launching", launching.state) - assertNull(launching.reason) - assertEquals(1, launching.generation) - assertEquals(43751, launching.proxyPort) - assertEquals(43650, launching.httpPort) - assertNull(launching.pid) - assertEquals(1, launching.cursor) - - session.apply(Hello(first.httpPort, link, pid = 7)) - val connected = session.snapshot() - assertEquals("connected", connected.state) - assertEquals(7, connected.pid) - assertEquals("My Server", connected.target) - } - - @Test - fun `the link is available only while a client is connected`() { - val session = Session(SessionId("s1"), target(1, "My Server")) - assertEquals( - "session s1 has no connected client: not started", - assertFailsWith { session.requireLink() }.message, - ) - session.apply(Launched(first)) - assertEquals( - "session s1 is still launching; call session_start with this session to wait for it", - assertFailsWith { session.requireLink() }.message, - ) - session.apply(Hello(first.httpPort, link, pid = 7)) - assertSame(link, session.requireLink()) - session.apply(LinkClosed(link)) - assertEquals( - "session s1 has no connected client: client exited", - assertFailsWith { session.requireLink() }.message, - ) - } - - @Test - fun `awaiting a launching session returns at the deadline without throwing`() { - val session = Session(SessionId("s1"), target(1, "My Server")) - session.apply(Launched(first)) - assertEquals(Launching(first), session.awaitConnected(30)) - assertEquals(Launching(first), session.awaitConnected(0)) - } - - @Test - fun `awaiting a launching session returns when it connects`() { - val session = Session(SessionId("s1"), target(1, "My Server")) - session.apply(Launched(first)) - val waiter = CompletableFuture.supplyAsync { session.awaitConnected(30_000) } - session.apply(Hello(first.httpPort, link, pid = 7)) - assertEquals(Connected(first, link, pid = 7), waiter.get(10, TimeUnit.SECONDS)) - } - - @Test - fun `an update from an earlier login does not change the current login`() { - val logins = LoginRegistry() - val old = logins.nextEpoch() - logins.login(old, revision = 241, world = 1) - logins.update(old) { it.copy(name = "first") } - val new = logins.nextEpoch() - logins.login(new, revision = 241, world = 2) - - logins.update(old) { it.copy(online = false, name = "stale") } - logins.login(old, revision = 241, world = 1) - - assertEquals(LoginInfo(new, 241, 2, name = null, online = true, transcribing = false), logins.current()) - } - - @Test - fun `a reconnect within one login keeps what is known about it`() { - val logins = LoginRegistry() - val epoch = logins.nextEpoch() - logins.login(epoch, revision = 241, world = 1) - logins.update(epoch) { it.copy(name = "tester", transcribing = true, online = false) } - logins.login(epoch, revision = 241, world = 1) - assertEquals(LoginInfo(epoch, 241, 1, name = "tester", online = true, transcribing = true), logins.current()) - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt b/mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt deleted file mode 100644 index 0bb50a13..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcpbridge/BridgeEndToEndTest.kt +++ /dev/null @@ -1,522 +0,0 @@ -package net.rsprox.mcpbridge - -import com.fasterxml.jackson.databind.JsonNode -import com.google.gson.Gson -import net.rsprox.mcp.bridge.BridgeError -import net.rsprox.mcp.bridge.Rendering as ServerRendering -import net.rsprox.mcp.bridge.TestHub -import net.rsprox.mcp.bridge.awaitTrue -import net.rsprox.mcp.packets.PacketQuery -import net.rsprox.mcp.packets.TapSettingSetStore -import net.rsprox.mcp.server.McpDispatcher -import net.rsprox.mcp.server.minimalArguments -import net.rsprox.mcp.server.tools -import net.rsprox.mcp.session.FakeLauncher -import net.rsprox.mcp.session.SessionManager -import net.runelite.api.GameState -import net.runelite.api.MenuAction -import java.awt.Color -import java.awt.Rectangle -import java.awt.event.KeyEvent -import java.awt.image.BufferedImage -import java.nio.file.Path -import java.util.Base64 -import java.util.concurrent.CompletableFuture -import java.util.concurrent.TimeUnit -import javax.imageio.ImageIO -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * The server and the plugin together: every tool call crosses the real hub, a loopback socket and the - * real plugin, and ends in a stand-in for the game. - */ -class BridgeEndToEndTest { - private val mapper = McpDispatcher.MAPPER - private val fixture = TestHub() - private val launcher = FakeLauncher() - private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub) - private val dispatcher = McpDispatcher(tools { manager }, "test") - private val game = FakeGame() - private val connections = ArrayList() - - @AfterTest - fun cleanUp() { - connections.forEach { it.close() } - fixture.close() - game.close() - } - - /** Starts the plugin as a launched client does. Null when the plugin stayed dormant. */ - private fun startPlugin( - rendezvous: Path, - httpPort: Int, - ): BridgeConnection? { - val ops = Ops(GameAccess(game.client, game.clientThread, game.frames)) - - return BridgeConnection.dial(rendezvous, httpPort, Gson(), ops)?.also { connections += it } - } - - /** Session s1, launched and connected to the plugin. */ - private fun connect(): BridgeConnection { - launcher.launch = { startPlugin(fixture.rendezvous, 43650) } - assertEquals("connected", manager.start(null, null, 10_000).state) - - return connections.single() - } - - private fun call( - name: String, - arguments: String = "{}", - ): JsonNode { - val body = """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$name","arguments":$arguments}}""" - - return mapper.readTree(dispatcher.handle("POST", null, body).body).get("result") - } - - private fun text( - name: String, - arguments: String = "{}", - ): String { - val result = call(name, arguments) - assertFalse(result.get("isError").asBoolean(), result.toString()) - - return result.get("content")[0].get("text").asText() - } - - private fun failure( - name: String, - arguments: String = "{}", - ): String { - val result = call(name, arguments) - assertTrue(result.get("isError").asBoolean(), result.toString()) - - return result.get("content")[0].get("text").asText() - } - - private fun cursor(): Long = manager.resolve("s1").packets.head().seq - - @Test - fun `a launched client says hello and its session connects with the client's pid`() { - launcher.launch = { startPlugin(fixture.rendezvous, 43650) } - - val started = mapper.readTree(text("session_start", """{"wait_ms":10000}""")) - - assertEquals("connected", started.get("state").asText()) - assertEquals(ProcessHandle.current().pid(), started.get("pid").asLong()) - assertEquals(Rendering.GPU, connections.single().rendering()) - assertEquals( - listOf("CLIENT_LAUNCHED", "CLIENT_CONNECTED"), - manager.resolve("s1").packets.read(PacketQuery()).packets.map { it.prot }, - ) - } - - @Test - fun `a server set to software rendering tells the plugin it welcomes`() { - TestHub(ServerRendering.SOFTWARE).use { software -> - val softwareManager = SessionManager(FakeLauncher(), TapSettingSetStore, software.hub) - softwareManager.start(null, null, 0) - - val connection = startPlugin(software.rendezvous, 43650) - - assertEquals(Rendering.SOFTWARE, connection?.rendering()) - assertEquals("connected", softwareManager.start(null, "s1", 10_000).state) - } - } - - @Test - fun `a client that no session expects stays dormant`() { - manager.start(null, null, 0) - - assertNull(startPlugin(fixture.rendezvous, 5)) - - assertEquals("launching", manager.list().single().state) - } - - @Test - fun `a client that rsprox did not launch stays dormant`() { - manager.start(null, null, 0) - - assertNull(startPlugin(fixture.rendezvous, -1)) - - assertEquals("launching", manager.list().single().state) - } - - @Test - fun `a client stays dormant while no server has left a rendezvous file`() { - assertNull(startPlugin(fixture.rendezvous.resolveSibling("absent.json"), 43650)) - } - - @Test - fun `a plugin that shuts down stops its session`() { - val connection = connect() - - connection.close() - - awaitTrue("the session is stopped") { manager.list().single().state == "stopped" } - assertEquals("client exited", manager.list().single().reason) - } - - @Test - fun `state on the login screen has no player`() { - connect() - - assertEquals( - """{"gameState":"LOGIN_SCREEN","tick":0,"canvas":[765,503],"world":301,"player":null,""" + - """"menu":{"open":false,"entries":[{"option":"Cancel","target":"","type":"CANCEL","id":0,""" + - """"p0":0,"p1":0}]},"cursor":${cursor()}}""", - text("client_state"), - ) - } - - @Test - fun `state in the game describes the player and the open menu`() { - connect() - game.logIn("McpProto") - game.menuOpen = true - game.menu = - listOf( - menuEntry("Cancel", "", MenuAction.CANCEL, 0, 0, 0), - menuEntry("Talk-to", "Guide", MenuAction.NPC_FIRST_OPTION, 3308, 52, 49), - ) - - val state = mapper.readTree(text("client_state")) - - assertEquals("LOGGED_IN", state.get("gameState").asText()) - assertEquals(mapper.readTree("""{"name":"McpProto","x":3222,"y":3218,"plane":0}"""), state.get("player")) - assertTrue(state.get("menu").get("open").asBoolean()) - assertEquals( - mapper.readTree( - """{"option":"Talk-to","target":"Guide","type":"NPC_FIRST_OPTION","id":3308, - "p0":52,"p1":49}""", - ), - state.get("menu").get("entries")[1], - ) - } - - /** Interface 558 under root 548: a button, a name field that is a dynamic child, and a hidden label. */ - private fun showDisplayNameInterface() { - val button = widget(558, 7, text = "Look up name", actions = listOf("Select", null, "")) - val field = widget(558, 12, index = 3, name = "Name field", bounds = Rectangle(467, 350, 332, 22)) - val layer = widget(558, 12, dynamic = listOf(field)) - val status = widget(558, 13, text = "Enter a name", hidden = true) - val container = widget(558, 0, static = listOf(button, layer, status)) - - game.roots = listOf(widget(548, 0, text = "Root", nested = listOf(container))) - } - - @Test - fun `widgets lists what can be read or clicked, with its bounds and click point`() { - connect() - showDisplayNameInterface() - - assertEquals( - """{"roots":[548],"widgets":[""" + - """{"id":"548:0","text":"Root","name":"","actions":[],"bounds":[0,0,10,10],"click":[5,5],""" + - """"type":4,"hidden":false},""" + - """{"id":"558:7","text":"Look up name","name":"","actions":["Select"],"bounds":[0,0,10,10],""" + - """"click":[5,5],"type":4,"hidden":false},""" + - """{"id":"558:12[3]","text":"","name":"Name field","actions":[],"bounds":[467,350,332,22],""" + - """"click":[633,361],"type":4,"hidden":false}""" + - """],"truncated":false,"cursor":${cursor()}}""", - text("client_widgets"), - ) - } - - @Test - fun `widgets narrows the list by text, by group and to hidden ones`() { - connect() - showDisplayNameInterface() - - fun ids(arguments: String): List = - mapper.readTree(text("client_widgets", arguments)).get("widgets").map { it.get("id").asText() } - - assertEquals(listOf("558:7", "558:12[3]"), ids("""{"text":"NAME"}""")) - assertEquals(listOf("558:7"), ids("""{"text":"select"}""")) - assertEquals(listOf("548:0"), ids("""{"group":548}""")) - assertEquals(listOf("558:7", "558:12[3]", "558:13"), ids("""{"group":558,"hidden":true}""")) - } - - @Test - fun `widgets stops at the limit and says the list was cut short`() { - connect() - showDisplayNameInterface() - - val listed = mapper.readTree(text("client_widgets", """{"limit":2}""")) - - assertEquals(listOf("548:0", "558:7"), listed.get("widgets").map { it.get("id").asText() }) - assertTrue(listed.get("truncated").asBoolean()) - } - - @Test - fun `vars reads each requested id`() { - connect() - game.varps[1055] = 7 - game.varbits[8119] = 1 - game.varcInts[5] = 0 - game.varcStrs[335] = "McpProto" - game.varcStrs[336] = null - - assertEquals( - """{"varps":{"1055":7},"varbits":{"8119":1},"varcInts":{"5":0},""" + - """"varcStrs":{"335":"McpProto","336":null},"cursor":${cursor()}}""", - text("client_vars", """{"varps":[1055],"varbits":[8119],"varcInts":[5],"varcStrs":[335,336]}"""), - ) - } - - @Test - fun `vars reports an id the client does not have as not found`() { - connect() - - val message = failure("client_vars", """{"varbits":[99999]}""") - - assertTrue(message.startsWith("not_found: varbit 99999 could not be read"), message) - } - - @Test - fun `login submits the credentials and returns once the client is in the game`() { - connect() - - assertEquals( - """{"gameState":"LOGGED_IN","cursor":${cursor()}}""", - text("client_login", """{"username":"mcptest","password":"secret"}"""), - ) - assertEquals("mcptest" to "secret", game.username to game.password) - } - - @Test - fun `login waits for a client that is still starting to reach the login screen`() { - connect() - game.startingReads = 2 - - text("client_login", """{"username":"mcptest","password":"secret"}""") - - assertEquals("mcptest", game.username) - assertEquals(GameState.LOGGED_IN, game.gameState) - } - - @Test - fun `a login the server refuses is reported as the wrong state`() { - connect() - game.loginAnswer = GameState.LOGIN_SCREEN - - assertEquals( - "wrong_state: the login was refused; the client is back on the login screen", - failure("client_login", """{"username":"mcptest","password":"wrong"}"""), - ) - } - - @Test - fun `a login times out when the client never reaches the login screen`() { - connect() - game.gameState = GameState.STARTING - - assertEquals( - "timeout: the client did not reach the login screen; gameState is STARTING. " + - "A client that cannot reach the game server stays in this state.", - failure("client_login", """{"username":"mcptest","password":"secret","wait_ms":0}"""), - ) - assertNull(game.username) - } - - @Test - fun `a login times out when the client stays logging in`() { - connect() - game.loginAnswer = GameState.LOGGING_IN - - assertEquals( - "timeout: not logged in before the wait elapsed; gameState is LOGGING_IN", - failure("client_login", """{"username":"mcptest","password":"secret","wait_ms":0}"""), - ) - } - - @Test - fun `a login from past the login screen is refused without touching the credentials`() { - connect() - game.logIn("McpProto") - - assertEquals( - "wrong_state: the client is not on the login screen: LOGGED_IN", - failure("client_login", """{"username":"mcptest","password":"secret"}"""), - ) - assertNull(game.username) - } - - @Test - fun `an empty password is a bad argument`() { - connect() - - assertEquals( - "bad_args: password is required and must not be empty", - failure("client_login", """{"username":"mcptest","password":""}"""), - ) - } - - @Test - fun `a call that waits on the game does not hold up the next one`() { - connect() - game.loginAnswer = GameState.LOGGING_IN - val login = - CompletableFuture.supplyAsync { - text("client_login", """{"username":"mcptest","password":"secret","wait_ms":60000}""") - } - - awaitTrue("the login is submitted") { game.gameState == GameState.LOGGING_IN } - - assertEquals("LOGGING_IN", mapper.readTree(text("client_state")).get("gameState").asText()) - assertFalse(login.isDone) - - game.gameState = GameState.LOGGED_IN - assertEquals("LOGGED_IN", mapper.readTree(login.get(10, TimeUnit.SECONDS)).get("gameState").asText()) - } - - @Test - fun `a click at coordinates moves, presses, releases and clicks the left button there`() { - connect() - - assertEquals("""{"x":380,"y":215,"cursor":${cursor()}}""", text("client_click", """{"x":380,"y":215}""")) - - assertEquals( - listOf( - "moved 380,215 button 0", - "pressed 380,215 button 1 holding 1", - "released 380,215 button 1", - "clicked 380,215 button 1", - ), - game.canvas.events, - ) - } - - @Test - fun `a right click uses the right button`() { - connect() - - text("client_click", """{"x":10,"y":20,"button":"right"}""") - - assertEquals( - listOf( - "moved 10,20 button 0", - "pressed 10,20 button 3 holding 3", - "released 10,20 button 3", - "clicked 10,20 button 3", - ), - game.canvas.events, - ) - } - - @Test - fun `a click on a widget lands on its centre`() { - connect() - showDisplayNameInterface() - - assertEquals("""{"x":5,"y":5,"cursor":${cursor()}}""", text("client_click", """{"widget":"558:7"}""")) - assertEquals("""{"x":633,"y":361,"cursor":${cursor()}}""", text("client_click", """{"widget":"558:12[3]"}""")) - - assertEquals( - listOf("pressed 5,5 button 1 holding 1", "pressed 633,361 button 1 holding 1"), - game.canvas.events.filter { it.startsWith("pressed") }, - ) - } - - @Test - fun `a click on a widget that is missing or hidden is not found and sends no input`() { - connect() - showDisplayNameInterface() - - assertEquals("not_found: widget 558:99 does not exist", failure("client_click", """{"widget":"558:99"}""")) - assertEquals("not_found: widget 558:7[9] does not exist", failure("client_click", """{"widget":"558:7[9]"}""")) - assertEquals("not_found: widget 558:13 is not visible", failure("client_click", """{"widget":"558:13"}""")) - assertEquals(emptyList(), game.canvas.events) - } - - @Test - fun `a click without a usable target is a bad argument`() { - connect() - - assertEquals("bad_args: pass either x and y, or widget", failure("client_click", """{"x":380}""")) - assertEquals( - """bad_args: widget must look like "558:7" or "558:7[3]"""", - failure("client_click", """{"widget":"name field"}"""), - ) - assertEquals(emptyList(), game.canvas.events) - } - - @Test - fun `type presses, types and releases each character, then Enter when asked`() { - connect() - - assertEquals("""{"typed":2,"cursor":${cursor()}}""", text("client_type", """{"text":"m1","enter":true}""")) - - assertEquals( - listOf( - "pressed code ${KeyEvent.VK_M} char ${'m'.code}", - "typed code 0 char ${'m'.code}", - "released code ${KeyEvent.VK_M} char ${'m'.code}", - "pressed code ${KeyEvent.VK_1} char ${'1'.code}", - "typed code 0 char ${'1'.code}", - "released code ${KeyEvent.VK_1} char ${'1'.code}", - "pressed code ${KeyEvent.VK_ENTER} char 10", - "typed code 0 char 10", - "released code ${KeyEvent.VK_ENTER} char 10", - ), - game.canvas.events, - ) - } - - @Test - fun `type without enter sends only the text`() { - connect() - - text("client_type", """{"text":"a"}""") - - assertEquals(3, game.canvas.events.size) - } - - @Test - fun `a screenshot is the next frame, scaled to the canvas so a pixel is a click coordinate`() { - connect() - val dense = BufferedImage(1530, 1006, BufferedImage.TYPE_INT_RGB) - val graphics = dense.createGraphics() - graphics.color = Color.RED - graphics.fillRect(0, 0, 1530, 1006) - graphics.dispose() - game.frame = dense - val cursor = cursor() - - val content = call("client_screenshot").get("content") - - assertEquals("image/png", content[0].get("mimeType").asText()) - val image = ImageIO.read(Base64.getDecoder().decode(content[0].get("data").asText()).inputStream()) - assertEquals(765 to 503, image.width to image.height) - assertEquals(Color.RED.rgb, image.getRGB(380, 215)) - assertEquals("""{"width":765,"height":503,"cursor":$cursor}""", content[1].get("text").asText()) - } - - @Test - fun `an op the plugin does not know is a bad argument`() { - connect() - val link = manager.resolve("s1").requireLink() - - val error = assertFailsWith { link.call("dance", mapper.createObjectNode(), 10_000) } - - assertEquals("bad_args" to "unknown op 'dance'", error.code to error.message) - } - - @Test - fun `every client tool has an op in the plugin`() { - connect() - val clientTools = tools { manager }.filter { it.name.startsWith("client_") } - assertTrue(clientTools.isNotEmpty()) - - for (tool in clientTools) { - val answer = call(tool.name, minimalArguments(tool).toString()).get("content").last().get("text").asText() - - assertFalse(answer.contains("unknown op"), "${tool.name}: $answer") - } - } -} diff --git a/mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt b/mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt deleted file mode 100644 index 54e9e1c6..00000000 --- a/mcp/src/test/kotlin/net/rsprox/mcpbridge/FakeGame.kt +++ /dev/null @@ -1,282 +0,0 @@ -package net.rsprox.mcpbridge - -import net.runelite.api.Client -import net.runelite.api.GameState -import net.runelite.api.Menu -import net.runelite.api.MenuAction -import net.runelite.api.MenuEntry -import net.runelite.api.Player -import net.runelite.api.coords.WorldPoint -import net.runelite.api.widgets.Widget -import net.runelite.client.ui.DrawManager -import java.awt.Canvas -import java.awt.Image -import java.awt.Rectangle -import java.awt.event.InputEvent -import java.awt.event.KeyEvent -import java.awt.event.KeyListener -import java.awt.event.MouseEvent -import java.awt.event.MouseListener -import java.awt.event.MouseMotionListener -import java.awt.image.BufferedImage -import java.lang.reflect.Proxy -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Executor -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.function.Consumer - -/** - * An instance of the interface [T] that answers the methods named in [answers] and fails on any other, - * so a test notices a call it did not set up. - */ -internal inline fun fake(vararg answers: Pair) -> Any?>): T { - val byName = answers.toMap() - val type = T::class.java - - return type.cast( - Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { proxy, method, arguments -> - when (method.name) { - in byName -> byName.getValue(method.name)(arguments?.toList().orEmpty()) - "toString" -> "fake ${type.simpleName}" - "hashCode" -> System.identityHashCode(proxy) - "equals" -> proxy === arguments[0] - else -> error("the fake ${type.simpleName} has no answer for ${method.name}") - } - }, - ) -} - -internal fun widget( - group: Int, - child: Int, - index: Int = -1, - text: String? = null, - name: String? = null, - actions: List? = null, - bounds: Rectangle = Rectangle(0, 0, 10, 10), - hidden: Boolean = false, - static: List = emptyList(), - dynamic: List = emptyList(), - nested: List? = null, -): Widget = - fake( - "getId" to { (group shl 16) or child }, - "getIndex" to { index }, - "getText" to { text }, - "getName" to { name }, - "getActions" to { actions?.toTypedArray() }, - "getBounds" to { bounds }, - "isHidden" to { hidden }, - "getType" to { 4 }, - "getStaticChildren" to { static.toTypedArray() }, - "getDynamicChildren" to { dynamic.toTypedArray() }, - "getNestedChildren" to { nested?.toTypedArray() }, - "getChild" to { arguments -> dynamic.firstOrNull { it.index == arguments[0] } }, - ) - -internal fun menuEntry( - option: String, - target: String, - type: MenuAction, - id: Int, - param0: Int, - param1: Int, -): MenuEntry = - fake( - "getOption" to { option }, - "getTarget" to { target }, - "getType" to { type }, - "getIdentifier" to { id }, - "getParam0" to { param0 }, - "getParam1" to { param1 }, - ) - -/** A game canvas with no window, which records the input events it is sent. */ -internal class RecordingCanvas( - width: Int, - height: Int, -) : Canvas(), - MouseListener, - MouseMotionListener, - KeyListener { - val events = CopyOnWriteArrayList() - - init { - setSize(width, height) - addMouseListener(this) - addMouseMotionListener(this) - addKeyListener(this) - } - - // AWT only delivers key events to a component that is on screen, and one without a window never is. - override fun isShowing(): Boolean = true - - override fun isDisplayable(): Boolean = true - - private fun mouse( - kind: String, - event: MouseEvent, - ) { - val held = - when { - event.modifiersEx and InputEvent.BUTTON1_DOWN_MASK != 0 -> " holding 1" - event.modifiersEx and InputEvent.BUTTON3_DOWN_MASK != 0 -> " holding 3" - else -> "" - } - - events += "$kind ${event.x},${event.y} button ${event.button}$held" - } - - private fun key( - kind: String, - event: KeyEvent, - ) { - events += "$kind code ${event.keyCode} char ${event.keyChar.code}" - } - - override fun mouseMoved(event: MouseEvent) = mouse("moved", event) - - override fun mousePressed(event: MouseEvent) = mouse("pressed", event) - - override fun mouseReleased(event: MouseEvent) = mouse("released", event) - - override fun mouseClicked(event: MouseEvent) = mouse("clicked", event) - - override fun keyPressed(event: KeyEvent) = key("pressed", event) - - override fun keyTyped(event: KeyEvent) = key("typed", event) - - override fun keyReleased(event: KeyEvent) = key("released", event) - - override fun mouseDragged(event: MouseEvent) { - // - } - - override fun mouseEntered(event: MouseEvent) { - // - } - - override fun mouseExited(event: MouseEvent) { - // - } -} - -/** - * The game as the plugin sees it: a [Client], the thread it must be read on, and the renderer that - * hands out frames. A test sets the fields to describe the game, and reads them to see what the - * plugin did to it. - */ -internal class FakeGame : AutoCloseable { - val canvas = RecordingCanvas(765, 503) - - @Volatile var gameState = GameState.LOGIN_SCREEN - - /** How many more reads of the game state answer STARTING before [gameState] shows. */ - @Volatile var startingReads = 0 - - /** The state the game server puts the client in when it submits a login. */ - @Volatile var loginAnswer = GameState.LOGGED_IN - - @Volatile var username: String? = null - - @Volatile var password: String? = null - - @Volatile var player: Player? = null - - @Volatile var menuOpen = false - - @Volatile var menu = listOf(menuEntry("Cancel", "", MenuAction.CANCEL, 0, 0, 0)) - - @Volatile var roots = emptyList() - - @Volatile var frame = BufferedImage(765, 503, BufferedImage.TYPE_INT_RGB) - - val varps = HashMap() - val varbits = HashMap() - val varcInts = HashMap() - val varcStrs = HashMap() - - private val gameThread = singleThread("fake-client-thread") - private val renderThread = singleThread("fake-render-thread") - - val clientThread: Executor = gameThread - - /** Hands [frame] to each requested listener on the render thread, as the client does after a draw. */ - val frames: DrawManager = - object : DrawManager() { - override fun requestNextFrameListener(listener: Consumer) { - super.requestNextFrameListener(listener) - renderThread.execute { processDrawComplete { frame } } - } - } - - val client: Client = - fake( - "getGameState" to { readGameState() }, - "setGameState" to { arguments -> submitLogin(arguments[0] as GameState) }, - "setUsername" to { arguments -> username = arguments[0] as String }, - "setPassword" to { arguments -> password = arguments[0] as String }, - "getTickCount" to { 0 }, - "getCanvas" to { canvas }, - "getWorld" to { 301 }, - "getLocalPlayer" to { player }, - "isMenuOpen" to { menuOpen }, - "getMenu" to { fake("getMenuEntries" to { menu.toTypedArray() }) }, - "getWidgetRoots" to { roots.toTypedArray() }, - "getWidget" to { arguments -> find(roots, (arguments[0] as Int shl 16) or arguments[1] as Int) }, - // The client indexes its tables with the id and throws for one that does not exist. - "getVarpValue" to { arguments -> varps.getValue(arguments[0] as Int) }, - "getVarbitValue" to { arguments -> varbits.getValue(arguments[0] as Int) }, - "getVarcIntValue" to { arguments -> varcInts.getValue(arguments[0] as Int) }, - "getVarcStrValue" to { arguments -> varcStrs.getValue(arguments[0] as Int) }, - ) - - fun logIn(name: String) { - player = - fake( - "getName" to { name }, - "getWorldLocation" to { WorldPoint(3222, 3218, 0) }, - ) - - gameState = GameState.LOGGED_IN - } - - override fun close() { - gameThread.shutdownNow() - renderThread.shutdownNow() - } - - private fun readGameState(): GameState { - if (startingReads == 0) return gameState - - startingReads-- - - return GameState.STARTING - } - - private fun submitLogin(requested: GameState) { - check(requested == GameState.LOGGING_IN) { "the plugin only ever asks the client to log in" } - - gameState = loginAnswer - } - - private fun find( - widgets: List, - id: Int, - ): Widget? { - for (widget in widgets) { - if (widget.id == id && widget.index < 0) return widget - - val below = find(widget.staticChildren.toList() + widget.nestedChildren.orEmpty(), id) - if (below != null) return below - } - - return null - } - - private fun singleThread(name: String): ExecutorService = - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, name).apply { isDaemon = true } - } -} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java new file mode 100644 index 00000000..0484ae0d --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java @@ -0,0 +1,42 @@ +package net.rsprox.mcpbridge; + +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Shape; + +/** + * Picks the canvas point to aim at inside a clickable shape. The centre comes first, and later + * attempts move to other interior points, because another entity or an interface may cover a part + * of the shape. + */ +final class Aiming { + /** The positions to try within the bounds of a shape, as fractions of its width and height. */ + private static final double[][] POSITIONS = { + {0.5, 0.5}, {0.5, 0.25}, {0.5, 0.75}, {0.25, 0.5}, {0.75, 0.5}, + {0.25, 0.25}, {0.75, 0.75}, {0.25, 0.75}, {0.75, 0.25}, + }; + + /** Not for instances. */ + private Aiming() { + // + } + + /** + * Get the point of the shape to aim at on the given attempt, or null when no tried point of the + * shape lies within the area. The attempt chooses which position comes first, so attempts differ. + */ + static Point point(Shape shape, Rectangle area, int attempt) { + Rectangle bounds = shape.getBounds().intersection(area); + if (bounds.isEmpty()) return null; + + for (int i = 0; i < POSITIONS.length; i++) { + double[] position = POSITIONS[(attempt - 1 + i) % POSITIONS.length]; + int x = bounds.x + (int) (bounds.width * position[0]); + int y = bounds.y + (int) (bounds.height * position[1]); + + if (shape.contains(x, y) && area.contains(x, y)) return new Point(x, y); + } + + return null; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java index e654888b..9458a050 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -14,6 +14,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.OptionalInt; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; @@ -27,22 +28,49 @@ * to a small worker pool, so a request that waits on the game never holds up the next one. */ final class BridgeConnection implements AutoCloseable { + /** The logger of the connection. */ private static final Logger log = LoggerFactory.getLogger(BridgeConnection.class); + + /** The version of the wire protocol that this plugin speaks. */ private static final int PROTOCOL = 1; + + /** The longest wait for rsprox to accept the socket. */ private static final int CONNECT_TIMEOUT_MS = 2_000; + + /** The longest wait for rsprox to answer the hello. */ private static final int HELLO_TIMEOUT_MS = 5_000; + + /** The number of requests that can run at the same time. */ private static final int WORKERS = 4; + + /** The start of the argument in which rsprox hands a client its jav_config, up to the port. */ private static final String JAV_CONFIG_PREFIX = "--jav_config=http://127.0.0.1:"; + /** The socket to rsprox. */ private final Socket socket; + + /** The source of the requests from rsprox. */ private final BufferedReader reader; + + /** The sink of the replies, which each writer locks for the length of one line. */ private final BufferedWriter writer; + + /** The JSON serializer, which writes nulls. */ private final Gson gson; + + /** The ops that requests run. */ private final Ops ops; + + /** The rendering that rsprox asked for in its welcome. */ private final Rendering rendering; + + /** Whether the connection has been closed. */ private final AtomicBoolean closed = new AtomicBoolean(); + + /** The pool that runs the requests. */ private ExecutorService workers; + /** Create a connection over a socket that rsprox has welcomed. */ private BridgeConnection( Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops, Rendering rendering) { this.rendering = rendering; @@ -54,14 +82,11 @@ private BridgeConnection( } /** - * Connects to the rsprox that launched this client. Returns null, having started no thread, when - * there is no rsprox to talk to or it does not expect this client. A negative {@code httpPort} - * means rsprox did not launch this client. + * Connect to the rsprox that launched this client on {@code httpPort}. Returns null, having started + * no thread, when there is no rsprox to talk to or it does not expect this client. */ static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops ops) { - if (httpPort < 0 || !Files.isRegularFile(rendezvous)) { - return null; - } + if (!Files.isRegularFile(rendezvous)) return null; // The protocol spells out an absent player as null, which the client's Gson would drop. Gson gson = clientGson.newBuilder().serializeNulls().create(); @@ -103,7 +128,7 @@ static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops socket.setSoTimeout(0); boolean software = answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); - Rendering rendering = software ? Rendering.SOFTWARE : Rendering.GPU; + Rendering rendering = Rendering.fromWire(software); BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, rendering); connection.start(); log.info("rsprox MCP bridge connected as session {}", answer.get("session")); @@ -122,38 +147,40 @@ static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops } } - /** {@link Rendering#SOFTWARE} when rsprox asked for a client that renders without the GPU plugin. */ + /** Get the rendering, which is {@link Rendering#SOFTWARE} when rsprox asked for a client without the GPU plugin. */ Rendering rendering() { return rendering; } - /** The port in the client's jav_config argument, which is how rsprox tells its clients apart. */ - static int httpPortFromCommandLine() { - String command = System.getProperty("sun.java.command", ""); - int start = command.indexOf(JAV_CONFIG_PREFIX); - - if (start < 0) { - return -1; - } + /** + * Get the port in the jav_config argument of a client's command line, which is how rsprox tells its + * clients apart. Empty when rsprox did not launch the client. + */ + static OptionalInt httpPort(String commandLine) { + int start = commandLine.indexOf(JAV_CONFIG_PREFIX); + if (start < 0) return OptionalInt.empty(); start += JAV_CONFIG_PREFIX.length(); + int end = start; - while (end < command.length() && Character.isDigit(command.charAt(end))) { + while (end < commandLine.length() && Character.isDigit(commandLine.charAt(end))) { end++; } try { - return Integer.parseInt(command.substring(start, end)); + return OptionalInt.of(Integer.parseInt(commandLine.substring(start, end))); } catch (NumberFormatException e) { - return -1; + return OptionalInt.empty(); } } + /** Start the worker pool and the thread that reads requests. */ private void start() { workers = Executors.newFixedThreadPool(WORKERS, runnable -> daemon(runnable, "rsprox-mcp-bridge-worker")); daemon(this::readLoop, "rsprox-mcp-bridge-reader").start(); } + /** Create a daemon thread, which never keeps the client alive. */ private static Thread daemon(Runnable runnable, String name) { Thread thread = new Thread(runnable, name); thread.setDaemon(true); @@ -161,6 +188,7 @@ private static Thread daemon(Runnable runnable, String name) { return thread; } + /** Hand each request to the workers until the connection ends, then close it. */ private void readLoop() { try { String line; @@ -181,6 +209,7 @@ private void readLoop() { } } + /** Run one request and write its reply, which carries {@code err} when the op failed. */ private void answer(long id, String op, JsonObject args) { JsonObject reply = new JsonObject(); reply.addProperty("id", id); @@ -205,6 +234,7 @@ private void answer(long id, String op, JsonObject args) { } } + /** Build the {@code err} member of a reply. */ private static JsonObject error(String code, String message) { JsonObject error = new JsonObject(); error.addProperty("code", code); @@ -213,11 +243,10 @@ private static JsonObject error(String code, String message) { return error; } + /** Close the socket and stop the workers. Idempotent. */ @Override public void close() { - if (!closed.compareAndSet(false, true)) { - return; - } + if (!closed.compareAndSet(false, true)) return; try { socket.close(); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeDial.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeDial.java new file mode 100644 index 00000000..e0c73c92 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeDial.java @@ -0,0 +1,74 @@ +package net.rsprox.mcpbridge; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * One attempt to reach rsprox, and the connection it makes. Dialling blocks for seconds, so it runs on a + * thread of its own. Whoever closes the dial also closes a connection that the attempt makes later. + */ +final class BridgeDial implements AutoCloseable { + /** Whether the dial has been closed. Guarded by this. */ + private boolean closed; + + /** The connection the attempt made, or null while there is none. Guarded by this. */ + private BridgeConnection connection; + + /** Create a dial that has not been attempted yet. */ + private BridgeDial() { + // + } + + /** + * Run {@code dial} on a daemon thread, and hand the connection it returns to {@code onConnected} on + * that thread. A dial that returns null, or that was closed in the meantime, hands over nothing. + */ + static BridgeDial start(Supplier dial, Consumer onConnected) { + BridgeDial attempt = new BridgeDial(); + Thread thread = new Thread(() -> attempt.run(dial, onConnected), "rsprox-mcp-bridge-dial"); + thread.setDaemon(true); + thread.start(); + + return attempt; + } + + /** Dial unless the dial was closed first, and keep the connection unless it was closed meanwhile. */ + private void run(Supplier dial, Consumer onConnected) { + if (isClosed()) return; + + BridgeConnection made = dial.get(); + if (made == null) return; + + if (!keep(made)) { + made.close(); + + return; + } + + onConnected.accept(made); + } + + /** Determine if the dial has been closed. */ + private synchronized boolean isClosed() { + return closed; + } + + /** Take ownership of the connection, and determine if the dial was still open to take it. */ + private synchronized boolean keep(BridgeConnection made) { + if (closed) return false; + + connection = made; + + return true; + } + + /** Close the connection, if there is one, and any that the attempt still makes. Idempotent. */ + @Override + public synchronized void close() { + closed = true; + + if (connection != null) { + connection.close(); + } + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java index aba42756..8d07b6b3 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeException.java @@ -2,8 +2,10 @@ /** A failure reported to rsprox as {@code err}. Codes: bad_args, not_found, wrong_state, timeout, internal. */ final class BridgeException extends Exception { + /** The code that tells rsprox what kind of failure this is. */ final String code; + /** Create a failure with the given code and message. */ BridgeException(String code, String message) { super(message); this.code = code; diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java new file mode 100644 index 00000000..406af9c2 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java @@ -0,0 +1,133 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +/** + * Reads and turns the camera. A turn waits until the camera has settled, within a bound, because the + * client moves the camera toward its target over several ticks. + */ +final class Camera implements Ops.Op { + /** The number of frames over which the camera must not move to count as settled. */ + private static final int SETTLED_FRAMES = 3; + + /** The most frames a turn is given to settle. */ + private static final int TURN_FRAMES = 300; + + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** Create the camera op for the given game. */ + Camera(GameAccess game) { + this.game = game; + } + + /** + * Turn the camera as asked, or read it, and report its yaw and pitch, with whether the looked-at + * target is on screen. + */ + @Override + public JsonElement run(JsonObject args) throws BridgeException { + Integer yaw = Ops.optionalInt(args, "yaw"); + Integer pitch = Ops.optionalInt(args, "pitch"); + String lookAt = Ops.optionalString(args, "look_at"); + requireRanges(yaw, pitch); + Target target = lookAt == null ? null : locate(lookAt, args); + boolean turned = game.onClientThread(client -> turn(client, yaw, pitch, target)); + + if (turned) settle(); + + return game.onClientThread(client -> describe(client, target)); + } + + /** Check that the angles are within the client's ranges. Throws {@code bad_args} when one is not. */ + private static void requireRanges(Integer yaw, Integer pitch) throws BridgeException { + boolean badYaw = yaw != null && (yaw < 0 || yaw >= CameraTurn.FULL_TURN); + if (badYaw) throw new BridgeException("bad_args", "yaw must be from 0 to " + (CameraTurn.FULL_TURN - 1)); + + boolean badPitch = pitch != null && (pitch < CameraTurn.PITCH_MIN || pitch > CameraTurn.PITCH_MAX); + String range = "pitch must be from " + CameraTurn.PITCH_MIN + " to " + CameraTurn.PITCH_MAX; + + if (badPitch) throw new BridgeException("bad_args", range); + } + + /** Find the target of the kind that look_at names, from the same fields as an interaction takes. */ + private Target locate(String kind, JsonObject args) throws BridgeException { + JsonObject named = args.deepCopy(); + named.addProperty("target", kind); + Targets.requireIdentity(named); + + return game.onClientThread(client -> Targets.locate(client, named)); + } + + /** Set the camera's targets from the arguments, and report whether any was set. */ + private static boolean turn(Client client, Integer yaw, Integer pitch, Target target) throws BridgeException { + boolean turned = false; + + if (target != null) { + client.setCameraYawTarget(yawToward(client, target)); + turned = true; + } + + if (yaw != null) { + client.setCameraYawTarget(yaw); + turned = true; + } + + if (pitch != null) { + client.setCameraPitchTarget(pitch); + turned = true; + } + + return turned; + } + + /** Get the yaw that faces the target from the local player. Throws {@code wrong_state} while there is no player. */ + private static int yawToward(Client client, Target target) throws BridgeException { + Player local = client.getLocalPlayer(); + if (local == null) throw new BridgeException("wrong_state", "the client is not in the game"); + + WorldPoint tile = target.tile(client); + String refusal = target.label() + " is not in the world, so the camera cannot look at it"; + + if (tile == null) throw new BridgeException("not_found", refusal); + + LocalPoint there = LocalPoint.fromWorld(client.getTopLevelWorldView(), tile); + if (there == null) throw new BridgeException("not_found", target.label() + " is outside the loaded scene"); + + return CameraTurn.yawToward(local.getLocalLocation(), there); + } + + /** Wait until the yaw and the pitch have stopped changing for a few frames, within the bound. */ + private void settle() throws BridgeException { + long last = -1; + int still = 0; + + for (int frame = 0; frame < TURN_FRAMES && still < SETTLED_FRAMES; frame++) { + game.nextFrame(); + long now = game.onClientThread(Camera::angles); + still = now == last ? still + 1 : 0; + last = now; + } + } + + /** Get the yaw and the pitch packed into one value, so that a change in either shows. */ + private static long angles(Client client) { + return ((long) client.getCameraYaw() << Integer.SIZE) | client.getCameraPitch(); + } + + /** Describe the camera's yaw and pitch, and whether the target is on screen when one was looked at. */ + private static JsonObject describe(Client client, Target target) { + JsonObject out = new JsonObject(); + out.addProperty("yaw", client.getCameraYaw()); + out.addProperty("pitch", client.getCameraPitch()); + + if (target != null) out.addProperty("onScreen", target.aim(client, 1) != null); + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java new file mode 100644 index 00000000..0b412e1a --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java @@ -0,0 +1,99 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +/** + * Turns the camera toward a world tile: first the yaw that puts the tile in front of the camera, + * then, once the yaw has arrived and the tile is still not on screen, a middle pitch. Angles are in + * the client's unit, which has {@link #FULL_TURN} to a full turn. + */ +final class CameraTurn { + /** The number of angle units in a full turn. */ + static final int FULL_TURN = 16384; + + /** The lowest pitch the client accepts. */ + static final int PITCH_MIN = 1024; + + /** The highest pitch the client accepts. */ + static final int PITCH_MAX = 3064; + + /** The pitch that shows a good stretch of ground in front of the player. */ + static final int PITCH_MIDDLE = 2048; + + /** The distance within which the yaw counts as having arrived at its target. */ + private static final int ARRIVED_WITHIN = 64; + + /** The yaw before the turn, or null while the camera has not been turned. */ + private Integer yawBefore; + + /** The yaw the camera was sent to, or null while it has not been turned. */ + private Integer yawTarget; + + /** The pitch before the camera was pitched, or null while it has not been. */ + private Integer pitchBefore; + + /** Get the yaw that puts the tile in front of the local player's camera. */ + static int yawToward(LocalPoint from, LocalPoint to) { + double radians = Math.atan2(from.getX() - to.getX(), to.getY() - from.getY()); + int units = (int) Math.round(radians / (2 * Math.PI) * FULL_TURN); + + return Math.floorMod(units, FULL_TURN); + } + + /** + * Turn the camera one step further toward the tile and say what was done. The first call sends + * the yaw; a later call, once the yaw has arrived, sends the pitch. Throws {@code wrong_state} + * while the client has no local player. + */ + String toward(Client client, WorldPoint tile) throws BridgeException { + Player local = client.getLocalPlayer(); + if (local == null) throw new BridgeException("wrong_state", "the client is not in the game"); + + if (yawTarget == null) return turnYaw(client, local, tile); + + if (pitchBefore == null && arrived(client)) return pitch(client); + + return "waiting for the camera, yaw " + client.getCameraYaw() + " of " + yawTarget; + } + + /** Send the camera to the yaw that faces the tile. */ + private String turnYaw(Client client, Player local, WorldPoint tile) { + LocalPoint there = LocalPoint.fromWorld(client.getTopLevelWorldView(), tile); + yawBefore = client.getCameraYaw(); + yawTarget = there == null ? yawBefore : yawToward(local.getLocalLocation(), there); + client.setCameraYawTarget(yawTarget); + + return "turned the camera from yaw " + yawBefore + " toward " + yawTarget; + } + + /** Send the camera to the middle pitch. */ + private String pitch(Client client) { + pitchBefore = client.getCameraPitch(); + client.setCameraPitchTarget(PITCH_MIDDLE); + + return "pitched the camera from " + pitchBefore + " toward " + PITCH_MIDDLE; + } + + /** Determine if the camera's yaw is at its target, give or take a little. */ + private boolean arrived(Client client) { + int away = Math.floorMod(client.getCameraYaw() - yawTarget, FULL_TURN); + + return Math.min(away, FULL_TURN - away) <= ARRIVED_WITHIN; + } + + /** Describe the turn as its yaw and pitch, each "before -> target", or null while the camera was not turned. */ + JsonObject describe() { + if (yawTarget == null) return null; + + JsonObject out = new JsonObject(); + out.addProperty("yaw", yawBefore + " -> " + yawTarget); + + if (pitchBefore != null) out.addProperty("pitch", pitchBefore + " -> " + PITCH_MIDDLE); + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java new file mode 100644 index 00000000..2fe9d0c6 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java @@ -0,0 +1,92 @@ +package net.rsprox.mcpbridge; + +import net.runelite.api.Client; +import net.runelite.api.events.MenuOptionClicked; + +/** + * Decides, on the click the client is about to perform, whether it is the intended one. It is armed + * just before a click of ours and disarmed on the first click the client reports, so that a click + * that is not ours is never judged. + */ +final class ClickGuard { + /** What became of one attempt. */ + static final class Verdict { + /** Whether the client performed the intended action. */ + final boolean passed; + + /** What happened, in a few words. */ + final String words; + + /** The client's tick count when the action was let through, or -1 when it was not. */ + final int tick; + + /** Create a verdict. */ + private Verdict(boolean passed, String words, int tick) { + this.passed = passed; + this.words = words; + this.tick = tick; + } + + /** Build the verdict of an attempt that the client performed on the given tick. */ + static Verdict passed(String words, int tick) { + return new Verdict(true, words, tick); + } + + /** Build the verdict of an attempt that performed nothing. */ + static Verdict missed(String words) { + return new Verdict(false, words, -1); + } + } + + /** The target whose option the click must perform. */ + private final Target target; + + /** Whether a click of ours is pending. */ + private boolean armed; + + /** The verdict on the pending click, or null until the client reports one. */ + private Verdict verdict; + + /** Create the guard for the target. */ + ClickGuard(Target target) { + this.target = target; + } + + /** Expect a click of ours next. */ + synchronized void arm() { + armed = true; + verdict = null; + } + + /** Determine if the client has reported the pending click. */ + synchronized boolean settled() { + return verdict != null; + } + + /** Stop expecting a click, and get the verdict, which is the given words when the client reported none. */ + synchronized Verdict disarm(String whenNone) { + armed = false; + + return verdict != null ? verdict : Verdict.missed(whenNone); + } + + /** + * Judge the click the client is about to perform. The intended one is let through. Any other is + * consumed, which makes the client send nothing for it. + */ + synchronized void on(Client client, MenuOptionClicked event) { + if (!armed) return; + + armed = false; + String words = Offer.words(event.getMenuEntry()); + + if (event.isConsumed()) { + verdict = Verdict.missed("another plugin consumed the click on " + words); + } else if (target.matches(event.getMenuEntry(), client)) { + verdict = Verdict.passed("the client performed " + words, client.getTickCount()); + } else { + event.consume(); + verdict = Verdict.missed("the click would have performed " + words + ", so it was cancelled"); + } + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java new file mode 100644 index 00000000..a9619bee --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java @@ -0,0 +1,304 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.NPCComposition; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Player; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileItem; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** Lists what is near the local player, with the options each thing offers. */ +final class Entities implements Ops.Op { + /** The kinds of entity, in the order the result lists them. */ + private static final List KINDS = List.of("npc", "object", "ground_item", "player"); + + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** Create the listing for the given game. */ + Entities(GameAccess game) { + this.game = game; + } + + /** List the entities that pass the kind, radius and name filters, nearest first, up to the limit. */ + @Override + public JsonElement run(JsonObject args) throws BridgeException { + Set kinds = kinds(args); + int radius = args.get("radius").getAsInt(); + String name = Ops.optionalString(args, "name"); + String needle = name == null ? null : name.toLowerCase(Locale.ROOT); + int limit = args.get("limit").getAsInt(); + + return game.onClientThread(client -> { + Player local = client.getLocalPlayer(); + if (local == null) throw new BridgeException("wrong_state", "the client is not in the game"); + + Survey survey = new Survey(client, local, radius, needle); + + if (kinds.contains("npc")) survey.addNpcs(); + + if (kinds.contains("player")) survey.addPlayers(); + + if (kinds.contains("object") || kinds.contains("ground_item")) survey.addTiles(kinds); + + return survey.result(limit); + }); + } + + /** Get the kinds to list. Throws {@code bad_args} for a kind that does not exist. */ + private static Set kinds(JsonObject args) throws BridgeException { + JsonElement asked = args.get("kinds"); + if (asked == null || asked.isJsonNull()) return new LinkedHashSet<>(KINDS); + + Set kinds = new LinkedHashSet<>(); + + for (JsonElement kind : asked.getAsJsonArray()) { + if (!KINDS.contains(kind.getAsString())) throw new BridgeException("bad_args", "kinds must be of " + KINDS); + + kinds.add(kind.getAsString()); + } + + return kinds; + } + + /** One entity that passed the filters. */ + private static final class Found implements Comparable { + /** The kind of the entity. */ + final String kind; + + /** The distance from the local player in tiles, where a diagonal step counts as one. */ + final int distance; + + /** The index or id that orders entities at the same distance. */ + final int key; + + /** The world x of the entity. */ + final int x; + + /** The world y of the entity. */ + final int y; + + /** The entity as the result lists it. */ + final JsonObject described; + + /** Create the record of one entity. */ + Found(String kind, int distance, int key, int x, int y, JsonObject described) { + this.kind = kind; + this.distance = distance; + this.key = key; + this.x = x; + this.y = y; + this.described = described; + } + + /** Order the nearest first, then by index or id, then by tile, so a listing is stable. */ + @Override + public int compareTo(Found other) { + if (distance != other.distance) return Integer.compare(distance, other.distance); + + if (key != other.key) return Integer.compare(key, other.key); + + if (x != other.x) return Integer.compare(x, other.x); + + return Integer.compare(y, other.y); + } + } + + /** Collects the entities around the local player on its plane, in the top-level world view. */ + private static final class Survey { + /** The entities collected so far. */ + private final List found = new ArrayList<>(); + + /** The game client. */ + private final Client client; + + /** The top-level world view. */ + private final WorldView view; + + /** The local player. */ + private final Player local; + + /** The tile of the local player. */ + private final WorldPoint origin; + + /** The greatest distance from the local player to keep. */ + private final int radius; + + /** The lower-case text a name must hold, or null to keep every name. */ + private final String needle; + + /** Create a survey around the local player with the given filters. */ + Survey(Client client, Player local, int radius, String needle) { + this.client = client; + this.view = client.getTopLevelWorldView(); + this.local = local; + this.origin = local.getWorldLocation(); + this.radius = radius; + this.needle = needle; + } + + /** Collect the NPCs that the client shows. */ + void addNpcs() { + for (NPC npc : view.npcs()) { + NPCComposition composition = npc.getTransformedComposition(); + if (composition == null) continue; + + WorldPoint at = npc.getWorldLocation(); + JsonObject described = new JsonObject(); + described.addProperty("index", npc.getIndex()); + described.addProperty("id", npc.getId()); + described.addProperty("name", Ops.nullToEmpty(composition.getName())); + described.addProperty("x", at.getX()); + described.addProperty("y", at.getY()); + described.add("options", strings(Scenes.offered(composition.getActions()))); + add("npc", npc.getIndex(), at.getX(), at.getY(), described); + } + } + + /** Collect the players other than the local one. */ + void addPlayers() { + JsonArray options = strings(Scenes.offered(client.getPlayerOptions())); + + for (Player player : view.players()) { + if (player == local) continue; + + WorldPoint at = player.getWorldLocation(); + JsonObject described = new JsonObject(); + described.addProperty("index", player.getId()); + described.addProperty("name", Ops.nullToEmpty(player.getName())); + described.addProperty("x", at.getX()); + described.addProperty("y", at.getY()); + described.add("options", options); + add("player", player.getId(), at.getX(), at.getY(), described); + } + } + + /** Collect the objects and the ground items of the asked kinds on every tile within the radius. */ + void addTiles(Set kinds) { + int centreX = origin.getX() - view.getBaseX(); + int centreY = origin.getY() - view.getBaseY(); + Tile[][] plane = view.getScene().getTiles()[view.getPlane()]; + + for (int x = Math.max(0, centreX - radius); x <= Math.min(view.getSizeX() - 1, centreX + radius); x++) { + for (int y = Math.max(0, centreY - radius); y <= Math.min(view.getSizeY() - 1, centreY + radius); y++) { + Tile tile = plane[x][y]; + if (tile == null) continue; + + if (kinds.contains("object")) addObjects(tile); + + if (kinds.contains("ground_item")) addGroundItems(tile); + } + } + } + + /** Collect the objects that have options and whose tile of origin is this tile, so each is listed once. */ + private void addObjects(Tile tile) { + Point at = tile.getSceneLocation(); + + for (TileObject object : Scenes.objects(tile)) { + if (!Scenes.origin(object, tile).equals(at)) continue; + + ObjectComposition shown = Scenes.shown(client, object.getId()); + List options = shown == null ? List.of() : Scenes.offered(shown.getActions()); + + if (options.isEmpty()) continue; + + JsonObject described = new JsonObject(); + described.addProperty("id", object.getId()); + described.addProperty("name", Ops.nullToEmpty(shown.getName())); + described.addProperty("x", worldX(at)); + described.addProperty("y", worldY(at)); + described.add("options", strings(options)); + add("object", object.getId(), worldX(at), worldY(at), described); + } + } + + /** Collect the items that lie on the tile. */ + private void addGroundItems(Tile tile) { + List items = tile.getGroundItems(); + if (items == null) return; + + Point at = tile.getSceneLocation(); + + for (TileItem item : items) { + JsonObject described = new JsonObject(); + described.addProperty("id", item.getId()); + described.addProperty("name", Ops.nullToEmpty(client.getItemDefinition(item.getId()).getName())); + described.addProperty("quantity", item.getQuantity()); + described.addProperty("x", worldX(at)); + described.addProperty("y", worldY(at)); + described.add("options", strings(Scenes.offered(Scenes.GROUND_ITEM_OPTIONS))); + add("ground_item", item.getId(), worldX(at), worldY(at), described); + } + } + + /** Keep the entity when its tile is within the radius and its name holds the needle. */ + private void add(String kind, int key, int x, int y, JsonObject described) { + int distance = Math.max(Math.abs(x - origin.getX()), Math.abs(y - origin.getY())); + if (distance > radius) return; + + String name = described.get("name").getAsString().toLowerCase(Locale.ROOT); + if (needle != null && !name.contains(needle)) return; + + found.add(new Found(kind, distance, key, x, y, described)); + } + + /** Get the world x of the scene tile. */ + private int worldX(Point scene) { + return view.getBaseX() + scene.getX(); + } + + /** Get the world y of the scene tile. */ + private int worldY(Point scene) { + return view.getBaseY() + scene.getY(); + } + + /** Build the result from the nearest entities, up to the limit, with one list per kind. */ + JsonObject result(int limit) { + Collections.sort(found); + JsonObject out = new JsonObject(); + JsonObject at = new JsonObject(); + at.addProperty("x", origin.getX()); + at.addProperty("y", origin.getY()); + at.addProperty("plane", origin.getPlane()); + out.add("origin", at); + + for (String kind : KINDS) { + out.add(kind + "s", new JsonArray()); + } + + for (Found entity : found.subList(0, Math.min(limit, found.size()))) { + out.getAsJsonArray(entity.kind + "s").add(entity.described); + } + + out.addProperty("truncated", found.size() > limit); + + return out; + } + } + + /** Build a JSON array of the values. */ + private static JsonArray strings(List values) { + JsonArray out = new JsonArray(); + + for (String value : values) { + out.add(value); + } + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java index 8cabf2a9..037b5dca 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java @@ -13,30 +13,66 @@ import java.util.function.Consumer; import javax.swing.SwingUtilities; import net.runelite.api.Client; +import net.runelite.api.events.GameTick; +import net.runelite.client.eventbus.EventBus; import net.runelite.client.ui.DrawManager; /** - * The only holder of the client, its canvas and the draw manager. Each is handed out solely inside a - * callback that runs on the thread it belongs to, so an op cannot touch game state from a worker thread. + * The only holder of the client, its canvas, the draw manager and the event bus. Each is handed out + * solely inside a callback that runs on the thread it belongs to, so an op cannot touch game state + * from a worker thread. */ -final class GameAccess { +final class GameAccess implements AutoCloseable { + /** The longest wait for the client thread or the renderer to answer. */ private static final long TIMEOUT_MS = 5_000; + /** The priority of the subscriptions, which is the default of the client's own plugins. */ + private static final float PRIORITY = 0; + + /** The game client, which is only handed out on its own thread. */ private final Client client; + + /** The executor that runs a task on the client thread. */ private final Executor clientThread; + + /** The source of rendered frames. */ private final DrawManager drawManager; - GameAccess(Client client, Executor clientThread, DrawManager drawManager) { + /** The bus that the client posts its events on. */ + private final EventBus eventBus; + + /** The subscription that keeps {@link #tickCycle} current, for as long as this access is open. */ + private final Subscription ticks; + + /** The game cycle at which the last game tick started, or the lowest value before the first. */ + private volatile int tickCycle = Integer.MIN_VALUE; + + /** Create the access to the given client, its thread, its renderer and its event bus. */ + GameAccess(Client client, Executor clientThread, DrawManager drawManager, EventBus eventBus) { this.client = client; this.clientThread = clientThread; this.drawManager = drawManager; + this.eventBus = eventBus; + this.ticks = subscribe(GameTick.class, (game, tick) -> tickCycle = game.getGameCycle()); } interface ClientCall { + /** Read from or act on the client, on the client thread. */ T call(Client client) throws BridgeException; } - /** Runs {@code body} on the client thread and waits for its result. */ + interface ClientEvent { + /** Handle an event that the client posted, on the thread it posted it from. */ + void on(Client client, T event); + } + + interface Subscription extends AutoCloseable { + /** Stop receiving the events. Idempotent. */ + @Override + void close(); + } + + /** Run {@code body} on the client thread and wait for its result. */ T onClientThread(ClientCall body) throws BridgeException { CompletableFuture result = new CompletableFuture<>(); clientThread.execute(() -> { @@ -50,7 +86,7 @@ T onClientThread(ClientCall body) throws BridgeException { return await(result, "the client thread"); } - /** Runs {@code body} on the AWT event thread, where synthetic input events must be dispatched. */ + /** Run {@code body} on the AWT event thread, where synthetic input events must be dispatched. */ void input(Consumer body) throws BridgeException { try { SwingUtilities.invokeAndWait(() -> body.accept(client.getCanvas())); @@ -62,7 +98,19 @@ void input(Consumer body) throws BridgeException { } } - /** A copy of the next rendered frame, at whatever size the renderer produced it. */ + /** Receive every event of the type until the subscription is closed. */ + Subscription subscribe(Class type, ClientEvent handler) { + EventBus.Subscriber subscriber = eventBus.register(type, event -> handler.on(client, event), PRIORITY); + + return () -> eventBus.unregister(subscriber); + } + + /** Get the game cycle at which the last game tick started, or the lowest value before the first. */ + int tickCycle() { + return tickCycle; + } + + /** Get a copy of the next rendered frame, at whatever size the renderer produced it. */ BufferedImage frame() throws BridgeException { CompletableFuture result = new CompletableFuture<>(); drawManager.requestNextFrameListener(image -> { @@ -77,6 +125,21 @@ BufferedImage frame() throws BridgeException { return await(result, "a rendered frame"); } + /** Wait until the client has drawn its next frame. The image is not kept. */ + void nextFrame() throws BridgeException { + CompletableFuture drawn = new CompletableFuture<>(); + drawManager.requestNextFrameListener(image -> drawn.complete(null)); + + await(drawn, "a rendered frame"); + } + + /** Stop following the game ticks. */ + @Override + public void close() { + ticks.close(); + } + + /** Draw the image into a new one that the renderer does not own. */ private static BufferedImage copy(Image image) { BufferedImage copy = new BufferedImage(image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_RGB); @@ -88,6 +151,10 @@ private static BufferedImage copy(Image image) { return copy; } + /** + * Wait for the result. Throws {@code timeout} when {@code what} does not answer in time, and the + * failure of the awaited work when it failed. + */ private static T await(CompletableFuture result, String what) throws BridgeException { try { return result.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); @@ -98,9 +165,7 @@ private static T await(CompletableFuture result, String what) throws Brid throw new BridgeException("internal", "interrupted while waiting for " + what); } catch (ExecutionException e) { Throwable cause = e.getCause(); - if (cause instanceof BridgeException) { - throw (BridgeException) cause; - } + if (cause instanceof BridgeException) throw (BridgeException) cause; throw new BridgeException("internal", String.valueOf(cause)); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java new file mode 100644 index 00000000..938e464c --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java @@ -0,0 +1,93 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Tile; +import net.runelite.api.TileItem; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** + * An item on the ground, named by its id and its world tile. Its menu entries carry the id as the + * identifier and the scene tile as the parameters. + */ +final class GroundItemTarget extends Target { + /** The menu actions that perform an option on a ground item. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.GROUND_ITEM_FIRST_OPTION, + MenuAction.GROUND_ITEM_SECOND_OPTION, + MenuAction.GROUND_ITEM_THIRD_OPTION, + MenuAction.GROUND_ITEM_FOURTH_OPTION, + MenuAction.GROUND_ITEM_FIFTH_OPTION, + MenuAction.EXAMINE_ITEM_GROUND); + + /** The id of the item. */ + private final int id; + + /** The name of the item. */ + private final String name; + + /** The world tile the item lies on. */ + private final WorldPoint at; + + /** Create the target for the item with the id on the world tile, named as the client names it. */ + GroundItemTarget(int id, String name, WorldPoint at, String option) { + super(option); + this.id = id; + this.name = name; + this.at = at; + } + + /** Get the words that name the item. */ + @Override + String label() { + return "ground item " + id + " (" + name + ")"; + } + + /** Describe the item with its id, name and world tile. */ + @Override + JsonObject describe() { + JsonObject out = described("OPOBJ", "ground_item"); + out.addProperty("id", id); + out.addProperty("name", name); + out.addProperty("x", at.getX()); + out.addProperty("y", at.getY()); + out.addProperty("option", option); + + return out; + } + + /** Get the outline of the item's tile, or null while the item is gone or the tile is not in front of the camera. */ + @Override + Shape shape(Client client) { + WorldView view = client.getTopLevelWorldView(); + Tile tile = Scenes.tile(view, at.getX(), at.getY()); + List items = tile == null ? null : tile.getGroundItems(); + + if (items == null || items.stream().noneMatch(item -> item.getId() == id)) return null; + + return Scenes.tilePolygon(client, view, at.getX() - view.getBaseX(), at.getY() - view.getBaseY()); + } + + /** Determine if the entry is this option on the item with this id on this tile. */ + @Override + boolean matches(MenuEntry entry, Client client) { + WorldView view = client.getTopLevelWorldView(); + boolean sameTile = + entry.getParam0() == at.getX() - view.getBaseX() && entry.getParam1() == at.getY() - view.getBaseY(); + + return FAMILY.contains(entry.getType()) && entry.getIdentifier() == id && sameTile && sameOption(entry); + } + + /** Get the world tile the item lies on. */ + @Override + WorldPoint tile(Client client) { + return at; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interact.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interact.java new file mode 100644 index 00000000..d91f862f --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interact.java @@ -0,0 +1,31 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** + * Performs one option on one thing in the game as a player does: with the real mouse, through the + * menu the client builds for it. The client then sends what it sends for any player's click. + */ +final class Interact implements Ops.Op { + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** The real mouse of the client. */ + private final Mouse mouse; + + /** Create the interactions for the given game, through the given mouse. */ + Interact(GameAccess game, Mouse mouse) { + this.game = game; + this.mouse = mouse; + } + + /** Perform the option on the target, or walk to the tile, and describe what the client did. */ + @Override + public JsonElement run(JsonObject args) throws BridgeException { + Targets.requireArguments(args); + Target target = game.onClientThread(client -> Targets.resolve(client, args)); + + return new Interaction(game, mouse, target).run(); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java new file mode 100644 index 00000000..563aeea6 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java @@ -0,0 +1,291 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.awt.Point; +import java.util.ArrayDeque; +import java.util.Deque; +import net.rsprox.mcpbridge.ClickGuard.Verdict; +import net.rsprox.mcpbridge.GameAccess.Subscription; +import net.runelite.api.Client; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.MenuOptionClicked; + +/** + * One interaction with a target, performed as a player performs it: the mouse is moved onto the + * target, the client builds its menu, and the entry is clicked, through the right-click menu when it + * is not the default. Each attempt is judged by the {@link ClickGuard} and repeated until the client + * performs the intended action or the deadline passes. The loop runs on the worker thread of the op. + */ +final class Interaction { + /** The time the client gives a game cycle. */ + private static final int CYCLE_MS = 20; + + /** The deadline for a target that is on screen: about three game ticks. */ + private static final int ON_SCREEN_DEADLINE_MS = 1_800; + + /** The deadline once the camera had to be turned, which takes the camera several ticks. */ + private static final int CAMERA_DEADLINE_MS = 6_000; + + /** The longest wait for a game tick to start, which is a little over one tick. */ + private static final int TICK_WAIT_MS = 800; + + /** How recently a game tick must have started for the wait for one to be skipped. */ + private static final int RECENT_TICK_MS = 100; + + /** The number of frames the client is given to report a click. */ + private static final int CLICK_FRAMES = 3; + + /** The frames to wait for the right-click menu to open after the press. */ + private static final int MENU_OPEN_FRAMES = 8; + + /** The number of attempts that the result and the failure keep the words of. */ + private static final int KEPT = 5; + + /** The message when an interface blocks the game view. */ + private static final String BLOCKED = "the client offers only Cancel; an open interface is blocking the game view"; + + /** The gateway to the client and the threads it must be used on. */ + private final GameAccess game; + + /** The real mouse of the client. */ + private final Mouse mouse; + + /** The target and the option to perform on it. */ + private final Target target; + + /** The judge of each click. */ + private final ClickGuard guard; + + /** The turn of the camera, when the target is out of view. */ + private final CameraTurn camera = new CameraTurn(); + + /** The words of the last attempts, oldest first. */ + private final Deque tried = new ArrayDeque<>(); + + /** The number of attempts so far. */ + private int attempts; + + /** The number of reads of the menu. */ + private int reads; + + /** The number of reads at which the client offered more than Cancel. */ + private int offeredMore; + + /** The game cycle at which the interaction began. */ + private int began; + + /** The game cycle after which no further attempt is made. */ + private int deadline; + + /** Create the interaction that performs the target's option through the given mouse. */ + Interaction(GameAccess game, Mouse mouse, Target target) { + this.game = game; + this.mouse = mouse; + this.target = target; + this.guard = new ClickGuard(target); + } + + /** + * Perform the interaction and describe it: the target, the number of attempts, the tick on which + * the client performed the action, the words of the last attempts and any turn of the camera. + * Throws {@code wrong_state} when the deadline passes first. + */ + JsonObject run() throws BridgeException { + try (Subscription clicks = game.subscribe(MenuOptionClicked.class, guard::on)) { + game.onClientThread(client -> { + Mouse.requireUnstretched(client); + + return null; + }); + + awaitTick(); + began = cycle(); + deadline = began + ON_SCREEN_DEADLINE_MS / CYCLE_MS; + + while (true) { + attempts++; + Point aim = game.onClientThread(client -> target.aim(client, attempts)); + Verdict verdict = aim == null ? bringIntoView() : attempt(aim); + remember(verdict.words); + + if (verdict.passed) return result(verdict); + + if (cycle() >= deadline) throw expired(); + } + } + } + + /** + * Wait for the start of a game tick, so that positions are fresh and the action lands in a known + * tick, unless one started a moment ago. The wait is bounded, since a client that is logged out + * has no ticks. + */ + private void awaitTick() throws BridgeException { + int start = cycle(); + if (game.tickCycle() >= start - RECENT_TICK_MS / CYCLE_MS) return; + + for (int i = 0; i < TICK_WAIT_MS / CYCLE_MS && game.tickCycle() < start; i++) { + game.nextFrame(); + } + } + + /** Get the client's game cycle. */ + private int cycle() throws BridgeException { + return game.onClientThread(Client::getGameCycle); + } + + /** + * Move the mouse onto the aim point, read the menu and click the intended entry. The menu may lag + * the pointer by a frame, so a missing entry is read once more before the attempt counts as missed. + */ + private Verdict attempt(Point aim) throws BridgeException { + mouse.hover(aim.x, aim.y); + Offer offer = read(aim); + + if (offer.match < 0) { + game.nextFrame(); + offer = read(aim); + } + + String at = " at " + Scenes.words(aim.x, aim.y); + if (offer.match < 0) return missed(offer, offerWords(offer) + at); + + if (!offer.underMouse) return missed(offer, target.label() + " moved out from under the mouse" + at); + + if (offer.isDefault() && !offer.open) return click(aim, "left-clicked " + offer.offered.get(offer.match) + at); + + return throughMenu(aim, offer); + } + + /** + * Open the right-click menu on the aim point, or use the one that is open, and click the intended + * row. The row is found again in the menu as the client reports it after opening, since the + * entries may be reordered or added to while it opens. + */ + private Verdict throughMenu(Point aim, Offer offer) throws BridgeException { + if (!offer.open) { + mouse.click(aim.x, aim.y, Mouse.Button.RIGHT); + offer = awaitOpenMenu(); + } + + if (!offer.open) return missed(offer, "the menu did not open on " + offer.words()); + + if (offer.match < 0) return missed(offer, "the open menu offers " + offer.words()); + + Point row = offer.row(); + mouse.hover(row.x, row.y); + Offer hovered = read(null); + boolean same = hovered.open && hovered.match == offer.match; + + if (!same) return missed(hovered, "the menu changed to " + hovered.words() + " while the row was hovered"); + + String words = "clicked row " + offer.rowNumber() + ", " + offer.offered.get(offer.match); + + return click(row, words + " at " + Scenes.words(row.x, row.y)); + } + + /** + * Wait for the right-click menu to open and read it once it has settled. The client opens the menu + * on its next game cycle, which can be several frames after the press, and fills it a frame later. + */ + private Offer awaitOpenMenu() throws BridgeException { + Offer offer = read(null); + + for (int i = 0; i < MENU_OPEN_FRAMES && !offer.open; i++) { + game.nextFrame(); + offer = read(null); + } + + if (!offer.open) return offer; + + game.nextFrame(); + + return read(null); + } + + /** Left-click the point under the guard, and wait a few frames for the client to report the click. */ + private Verdict click(Point at, String words) throws BridgeException { + guard.arm(); + mouse.click(at.x, at.y, Mouse.Button.LEFT); + + for (int i = 0; i < CLICK_FRAMES && !guard.settled(); i++) { + game.nextFrame(); + } + + Verdict verdict = guard.disarm("the client reported no click within " + CLICK_FRAMES + " frames"); + String outcome = words + "; " + verdict.words; + + return verdict.passed ? Verdict.passed(outcome, verdict.tick) : Verdict.missed(outcome); + } + + /** Record a missed attempt, closing the menu when it is open so that the next attempt starts afresh. */ + private Verdict missed(Offer offer, String words) throws BridgeException { + if (offer.open) { + Point away = offer.away(); + mouse.hover(away.x, away.y); + } + + return Verdict.missed(words); + } + + /** + * Read the menu the client has built, and whether the target still lies under the aim when one is + * given, and count the reads and those that offered more than Cancel. + */ + private Offer read(Point aim) throws BridgeException { + Offer offer = game.onClientThread(client -> new Offer(client, target, aim)); + reads++; + + if (!offer.onlyCancel) offeredMore++; + + return offer; + } + + /** Write what the client offered, naming the blocking interface when it offered only Cancel. */ + private static String offerWords(Offer offer) { + return offer.onlyCancel ? BLOCKED : "the client offered " + offer.words(); + } + + /** Turn the camera toward the target, which is not on screen, and extend the deadline for the turn. */ + private Verdict bringIntoView() throws BridgeException { + WorldPoint tile = game.onClientThread(target::tile); + String words = "no part of " + target.label() + " is on screen"; + game.nextFrame(); + + if (tile == null) return Verdict.missed(words); + + deadline = Math.max(deadline, began + CAMERA_DEADLINE_MS / CYCLE_MS); + + return Verdict.missed(words + "; " + game.onClientThread(client -> camera.toward(client, tile))); + } + + /** Keep the words of the attempt among the last few. */ + private void remember(String words) { + tried.addLast(words); + + if (tried.size() > KEPT) tried.removeFirst(); + } + + /** Build the result of the interaction that the client performed. */ + private JsonObject result(Verdict verdict) { + JsonObject out = target.describe(); + out.addProperty("attempts", attempts); + out.addProperty("tick", verdict.tick); + JsonArray words = new JsonArray(); + tried.forEach(words::add); + out.add("tried", words); + + if (camera.describe() != null) out.add("camera", camera.describe()); + + return out; + } + + /** Build the failure for a deadline that passed, with what happened at the last attempt. */ + private BridgeException expired() { + String last = reads > 0 && offeredMore == 0 ? BLOCKED : tried.peekLast(); + String message = "aimed at " + target.label() + "; " + last + " at the last of " + attempts + " attempts"; + + return new BridgeException("wrong_state", message); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index 64029deb..674018e2 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -3,9 +3,11 @@ import com.google.gson.Gson; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.OptionalInt; import javax.inject.Inject; import net.runelite.api.Client; import net.runelite.client.callback.ClientThread; +import net.runelite.client.eventbus.EventBus; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.PluginInstantiationException; @@ -25,34 +27,69 @@ hidden = true ) public class McpBridgePlugin extends Plugin { + /** The logger of the plugin. */ private static final Logger log = LoggerFactory.getLogger(McpBridgePlugin.class); + + /** The class name of the client's GPU plugin. */ private static final String GPU_PLUGIN = "GpuPlugin"; + + /** The longest wait for the GPU plugin to take over rendering. */ private static final long GPU_START_WAIT_MS = 60_000; + + /** The pause between two checks of whether the GPU plugin renders. */ private static final long GPU_POLL_MS = 100; + /** The game client. */ @Inject private Client client; + + /** The runner of tasks on the client thread. */ @Inject private ClientThread clientThread; + + /** The source of rendered frames. */ @Inject private DrawManager drawManager; + + /** The bus that the client posts its events on. */ + @Inject private EventBus eventBus; + + /** The client's JSON serializer. */ @Inject private Gson gson; + + /** The manager that starts and stops the client's plugins. */ @Inject private PluginManager pluginManager; - private BridgeConnection connection; + /** The access to the game that the ops share, or null while the plugin is dormant. */ + private GameAccess game; + /** The dial to rsprox and the connection it made, or null while the plugin is dormant. */ + private BridgeDial dial; + + /** + * Connect to the rsprox that launched this client, and stay dormant when there is none. + * The client calls this on its UI thread, so the dial runs on a thread of its own. + */ @Override protected void startUp() { + OptionalInt httpPort = BridgeConnection.httpPort(System.getProperty("sun.java.command", "")); + if (httpPort.isEmpty()) return; + // The server writes this path in McpMain.kt. Path rendezvous = Paths.get(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"); - Ops ops = new Ops(new GameAccess(client, clientThread::invoke, drawManager)); + game = new GameAccess(client, clientThread::invoke, drawManager, eventBus); + Ops ops = new Ops(game); + int port = httpPort.getAsInt(); - connection = BridgeConnection.dial(rendezvous, BridgeConnection.httpPortFromCommandLine(), gson, ops); - if (connection == null || connection.rendering() != Rendering.SOFTWARE) return; + dial = BridgeDial.start(() -> BridgeConnection.dial(rendezvous, port, gson, ops), this::onConnected); + } + + /** Stop the GPU plugin when rsprox asked for software rendering. Runs on the thread of the dial. */ + private void onConnected(BridgeConnection connection) { + if (connection.rendering() != Rendering.SOFTWARE) return; - Thread thread = new Thread(this::stopGpuPlugin, "mcp-bridge-gpu-off"); - thread.setDaemon(true); - thread.start(); + stopGpuPlugin(); } /** + * Stop the GPU plugin once it renders, so that screenshots show the game. * Frames read back from the GPU plugin are black on a virtual display. Stopping the plugin, instead * of disabling it, leaves the profile untouched for clients that share it. */ @@ -65,9 +102,7 @@ private void stopGpuPlugin() { } } - if (gpu == null || !pluginManager.isPluginEnabled(gpu)) { - return; - } + if (gpu == null || !pluginManager.isPluginEnabled(gpu)) return; // The GPU plugin finishes starting on the client thread, some time after it is marked active. // Stopping it before then lets that late start win, so wait until the client renders through it. @@ -93,11 +128,14 @@ private void stopGpuPlugin() { } } + /** Close the connection to rsprox, if there is one or the dial still makes one, and let go of the game. */ @Override protected void shutDown() { - if (connection == null) return; + if (dial == null) return; - connection.close(); - connection = null; + dial.close(); + dial = null; + game.close(); + game = null; } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java new file mode 100644 index 00000000..16bc7706 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java @@ -0,0 +1,97 @@ +package net.rsprox.mcpbridge; + +import java.awt.event.InputEvent; +import java.awt.event.MouseEvent; +import net.runelite.api.Client; +import net.runelite.api.Point; + +/** The real mouse of the client: synthetic events on its canvas, which the client treats as a player's input. */ +final class Mouse { + /** The pause between the press and the release of a click, as a player's finger takes. */ + private static final int CLICK_HOLD_MS = 30; + + enum Button { + /** The left button. */ + LEFT(MouseEvent.BUTTON1, InputEvent.BUTTON1_DOWN_MASK), + + /** The right button. */ + RIGHT(MouseEvent.BUTTON3, InputEvent.BUTTON3_DOWN_MASK); + + /** The button as a mouse event names it. */ + final int awt; + + /** The modifier that a press of the button holds. */ + final int downMask; + + /** Create the button with its event names. */ + Button(int awt, int downMask) { + this.awt = awt; + this.downMask = downMask; + } + } + + /** The gateway to the canvas and the thread it must be used on. */ + private final GameAccess game; + + /** Create the mouse of the given game. */ + Mouse(GameAccess game) { + this.game = game; + } + + /** + * Move the mouse to the canvas position. While the client tracks the pointer as off the canvas, it + * is first brought onto it, as happens when a pointer enters a window. + */ + void move(int x, int y) throws BridgeException { + boolean outside = game.onClientThread(Mouse::pointerOutside); + game.input(canvas -> { + long now = System.currentTimeMillis(); + + if (outside) canvas.dispatchEvent(new MouseEvent(canvas, MouseEvent.MOUSE_ENTERED, now, 0, x, y, 0, false)); + + canvas.dispatchEvent(new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, now, 0, x, y, 0, false)); + }); + } + + /** + * Move the mouse to the canvas position and wait for the frame that the client draws with it there. + * The client builds the menu for the scene while it draws, from where the mouse is at that time. + */ + void hover(int x, int y) throws BridgeException { + move(x, y); + game.nextFrame(); + } + + /** Press and release the button at the canvas position, where the mouse must already be. */ + void click(int x, int y, Button button) throws BridgeException { + game.input(canvas -> { + long now = System.currentTimeMillis(); + long released = now + CLICK_HOLD_MS; + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, now, button.downMask, x, y, 1, false, button.awt)); + + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_RELEASED, released, 0, x, y, 1, false, button.awt)); + + canvas.dispatchEvent( + new MouseEvent(canvas, MouseEvent.MOUSE_CLICKED, released, 0, x, y, 1, false, button.awt)); + }); + } + + /** + * Check that canvas units are what the client's mouse handling expects. Throws {@code wrong_state} + * in stretched mode, where the canvas is drawn at another size than its coordinates. + */ + static void requireUnstretched(Client client) throws BridgeException { + String refusal = "the client is in stretched mode, where a mouse position is not a canvas position"; + + if (client.isStretchedEnabled()) throw new BridgeException("wrong_state", refusal); + } + + /** Determine if the client tracks the pointer as off the canvas, which it reports as a negative position. */ + private static boolean pointerOutside(Client client) { + Point tracked = client.getMouseCanvasPosition(); + + return tracked == null || tracked.getX() < 0 || tracked.getY() < 0; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java new file mode 100644 index 00000000..4683d0f7 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java @@ -0,0 +1,85 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.NPC; +import net.runelite.api.coords.WorldPoint; + +/** An NPC, named by its index in the client's list. Its menu entries carry that index as the identifier. */ +final class NpcTarget extends Target { + /** The menu actions that perform an option on an NPC. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.NPC_FIRST_OPTION, + MenuAction.NPC_SECOND_OPTION, + MenuAction.NPC_THIRD_OPTION, + MenuAction.NPC_FOURTH_OPTION, + MenuAction.NPC_FIFTH_OPTION, + MenuAction.EXAMINE_NPC); + + /** The index of the NPC. */ + private final int index; + + /** The id of the NPC when it was found. */ + private final int id; + + /** The name of the NPC when it was found. */ + private final String name; + + /** Create the target for the NPC with the index, as found with the id and name. */ + NpcTarget(int index, int id, String name, String option) { + super(option); + this.index = index; + this.id = id; + this.name = name; + } + + /** Get the words that name the NPC. */ + @Override + String label() { + return "npc " + index + " (" + name + ")"; + } + + /** Describe the NPC with its index, id and name. */ + @Override + JsonObject describe() { + JsonObject out = described("OPNPC", "npc"); + out.addProperty("index", index); + out.addProperty("id", id); + out.addProperty("name", name); + out.addProperty("option", option); + + return out; + } + + /** Get the convex hull of the NPC's model, or null while the client does not show it. */ + @Override + Shape shape(Client client) { + NPC npc = find(client); + + return npc == null ? null : npc.getConvexHull(); + } + + /** Determine if the entry is this option on the NPC with this index. */ + @Override + boolean matches(MenuEntry entry, Client client) { + return FAMILY.contains(entry.getType()) && entry.getIdentifier() == index && sameOption(entry); + } + + /** Get the tile the NPC stands on, or null while the client does not have it. */ + @Override + WorldPoint tile(Client client) { + NPC npc = find(client); + + return npc == null ? null : npc.getWorldLocation(); + } + + /** Find the NPC in the client's list, or null while it is not there. */ + private NPC find(Client client) { + return client.getTopLevelWorldView().npcs().byIndex(index); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java new file mode 100644 index 00000000..8242e17f --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java @@ -0,0 +1,113 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** + * An object, named by its own id and its scene tile of origin. Its menu entries carry the id as the + * identifier and the tile as the parameters. + */ +final class ObjectTarget extends Target { + /** The menu actions that perform an option on an object. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.GAME_OBJECT_FIRST_OPTION, + MenuAction.GAME_OBJECT_SECOND_OPTION, + MenuAction.GAME_OBJECT_THIRD_OPTION, + MenuAction.GAME_OBJECT_FOURTH_OPTION, + MenuAction.GAME_OBJECT_FIFTH_OPTION, + MenuAction.EXAMINE_OBJECT); + + /** The object's own id. */ + private final int id; + + /** The name the client shows the object as. */ + private final String name; + + /** The world tile of origin. */ + private final WorldPoint origin; + + /** Create the target for the object with the id at its world tile of origin, shown with the name. */ + ObjectTarget(int id, String name, WorldPoint origin, String option) { + super(option); + this.id = id; + this.name = name; + this.origin = origin; + } + + /** Get the words that name the object. */ + @Override + String label() { + return "object " + id + " (" + name + ")"; + } + + /** Describe the object with its id, name and world tile of origin. */ + @Override + JsonObject describe() { + JsonObject out = described("OPLOC", "object"); + out.addProperty("id", id); + out.addProperty("name", name); + out.addProperty("x", origin.getX()); + out.addProperty("y", origin.getY()); + out.addProperty("option", option); + + return out; + } + + /** + * Get the clickbox of the object, falling back to the hull of a game object and then to its tile, + * or null while the object is gone or none of them is in front of the camera. + */ + @Override + Shape shape(Client client) { + TileObject object = find(client); + if (object == null) return null; + + Shape clickbox = object.getClickbox(); + if (clickbox != null) return clickbox; + + Shape hull = object instanceof GameObject ? ((GameObject) object).getConvexHull() : null; + + return hull != null ? hull : object.getCanvasTilePoly(); + } + + /** Determine if the entry is this option on the object with this id at this tile. */ + @Override + boolean matches(MenuEntry entry, Client client) { + WorldView view = client.getTopLevelWorldView(); + int sceneX = origin.getX() - view.getBaseX(); + int sceneY = origin.getY() - view.getBaseY(); + boolean sameTile = entry.getParam0() == sceneX && entry.getParam1() == sceneY; + + return FAMILY.contains(entry.getType()) && entry.getIdentifier() == id && sameTile && sameOption(entry); + } + + /** Get the world tile of origin. */ + @Override + WorldPoint tile(Client client) { + return origin; + } + + /** Find the object on its tile of origin, or null while it is not there. */ + private TileObject find(Client client) { + Tile tile = Scenes.tile(client.getTopLevelWorldView(), origin.getX(), origin.getY()); + Point at = tile == null ? null : tile.getSceneLocation(); + + for (TileObject object : tile == null ? List.of() : Scenes.objects(tile)) { + if (object.getId() == id && Scenes.origin(object, tile).equals(at)) return object; + } + + return null; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java new file mode 100644 index 00000000..a7c06c06 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java @@ -0,0 +1,138 @@ +package net.rsprox.mcpbridge; + +import java.awt.Canvas; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Shape; +import java.util.ArrayList; +import java.util.List; +import net.runelite.api.Client; +import net.runelite.api.Menu; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; + +/** + * What the client's menu offered at one moment, read on the client thread and kept as plain values: + * the entries in the client's array order, where the last is the one a left click performs, whether + * the aimed-at target was still under the mouse, and the row geometry of the menu when it is open. + */ +final class Offer { + /** + * The client's menu layout: a header of this height above the rows, then one row per entry, drawn + * top to bottom in reverse array order. The row height is derived from the menu height and the + * number of entries, and this value is used when that derivation gives nonsense. + */ + private static final int HEADER = 19; + + /** The row height of the client's menu layout, as a fallback. */ + private static final int ROW = 15; + + /** The least row height that counts as a sane derivation. */ + private static final int ROW_MIN = 10; + + /** The greatest row height that counts as a sane derivation. */ + private static final int ROW_MAX = 20; + + /** The distance from the edge of the canvas at which the mouse is parked to close a menu. */ + private static final int MARGIN = 2; + + /** Whether the menu was open. */ + final boolean open; + + /** The entries as "option target" with the colour tags stripped, in the client's array order. */ + final List offered = new ArrayList<>(); + + /** The index of the entry that performs the intended action, or -1 when none does. */ + final int match; + + /** Whether every entry is a cancel entry, as while an interface blocks the game view. */ + final boolean onlyCancel; + + /** Whether the target's outline still held the aimed-at point when the menu was read. */ + final boolean underMouse; + + /** The bounds of the open menu on the canvas. */ + private final Rectangle bounds; + + /** The number of rows the open menu is scrolled by. */ + private final int scroll; + + /** The size of the canvas. */ + private final Rectangle canvas; + + /** + * Read the menu the client has built, looking for the entry that performs the option on the + * target, and whether the target still lies under the aimed-at point, if one is given. + */ + Offer(Client client, Target target, Point aim) { + Menu menu = client.getMenu(); + MenuEntry[] entries = menu.getMenuEntries(); + int found = -1; + boolean cancels = true; + + for (int i = 0; i < entries.length; i++) { + offered.add(words(entries[i])); + cancels &= entries[i].getType() == MenuAction.CANCEL; + + if (target.matches(entries[i], client)) found = i; + } + + Shape shape = aim == null ? null : target.shape(client); + Canvas surface = client.getCanvas(); + this.open = client.isMenuOpen(); + this.match = found; + this.onlyCancel = cancels; + this.underMouse = aim == null || (shape != null && shape.contains(aim)); + this.bounds = new Rectangle(menu.getMenuX(), menu.getMenuY(), menu.getMenuWidth(), menu.getMenuHeight()); + this.scroll = client.getMenuScroll(); + this.canvas = new Rectangle(0, 0, surface.getWidth(), surface.getHeight()); + } + + /** Determine if the matching entry is the one a left click performs with the menu closed. */ + boolean isDefault() { + return match >= 0 && match == offered.size() - 1; + } + + /** Get the canvas point in the vertical middle of the matching entry's row of the open menu. */ + Point row() { + int row = offered.size() - 1 - match - scroll; + int height = rowHeight(); + + return new Point(bounds.x + bounds.width / 2, bounds.y + HEADER + height * row + height / 2); + } + + /** Get the row number of the matching entry, counted from the top of the open menu. */ + int rowNumber() { + return offered.size() - 1 - match - scroll; + } + + /** Get the height of a row, derived from the menu's height when that gives a sane value. */ + private int rowHeight() { + int derived = offered.isEmpty() ? 0 : (bounds.height - HEADER) / offered.size(); + + return derived >= ROW_MIN && derived <= ROW_MAX ? derived : ROW; + } + + /** Get the corner of the canvas farthest from the menu, where the mouse is out of the menu and it closes. */ + Point away() { + boolean left = bounds.getCenterX() > canvas.getCenterX(); + boolean top = bounds.getCenterY() > canvas.getCenterY(); + + return new Point(left ? MARGIN : canvas.width - MARGIN, top ? MARGIN : canvas.height - MARGIN); + } + + /** Write the entries as a bracketed list, in the client's array order. */ + String words() { + return offered.toString(); + } + + /** Write the entry as its option and its target with the colour tags stripped. */ + static String words(MenuEntry entry) { + return (untagged(entry.getOption()) + " " + untagged(entry.getTarget())).trim(); + } + + /** Get the text without its colour and other tags, or an empty string for null. */ + static String untagged(String text) { + return Ops.nullToEmpty(text).replaceAll("<[^>]*>", ""); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 99b7e274..1d4bfbb1 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -6,13 +6,12 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import java.awt.Canvas; +import java.awt.Dimension; import java.awt.Graphics2D; import java.awt.Point; import java.awt.Rectangle; import java.awt.RenderingHints; -import java.awt.event.InputEvent; import java.awt.event.KeyEvent; -import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -34,19 +33,39 @@ /** The operations rsprox can ask for, by name. Coordinates are canvas units everywhere. */ final class Ops { + /** The shape of a widget reference: a group, a child and an optional dynamic child index. */ private static final Pattern WIDGET_REF = Pattern.compile("(\\d+):(\\d+)(?:\\[(\\d+)])?"); + + /** The message for a widget reference that does not have that shape. */ + private static final String WIDGET_REF_SHAPE = "widget must look like \"558:7\" or \"558:7[3]\""; + + /** The deepest level of nested widgets that a walk descends to. */ private static final int WIDGET_DEPTH_LIMIT = 12; + + /** The pause between two reads of the game state while a login is awaited. */ private static final int LOGIN_POLL_MS = 100; + /** The message for a login that put the client back on the login screen. */ + private static final String LOGIN_REFUSED = "the login was refused; the client is back on the login screen"; + interface Op { + /** Run the op with the arguments that rsprox forwarded. */ JsonElement run(JsonObject args) throws BridgeException; } + /** The gateway to the client and the threads it must be used on. */ private final GameAccess game; + + /** The real mouse of the client. */ + private final Mouse mouse; + + /** The ops by the name rsprox calls them. */ private final Map table = new HashMap<>(); + /** Create the op table for the given game. */ Ops(GameAccess game) { this.game = game; + this.mouse = new Mouse(game); table.put("state", this::state); table.put("widgets", this::widgets); table.put("vars", this::vars); @@ -54,17 +73,20 @@ interface Op { table.put("click", this::click); table.put("type", this::type); table.put("login", this::login); + table.put("entities", new Entities(game)); + table.put("interact", new Interact(game, mouse)); + table.put("camera", new Camera(game)); } + /** Run the named op. Throws {@code bad_args} for a name that is not in the table. */ JsonElement run(String op, JsonObject args) throws BridgeException { Op handler = table.get(op); - if (handler == null) { - throw new BridgeException("bad_args", "unknown op '" + op + "'"); - } + if (handler == null) throw new BridgeException("bad_args", "unknown op '" + op + "'"); return handler.run(args); } + /** Describe the game state, the canvas, the local player and the right-click menu. */ private JsonElement state(JsonObject args) throws BridgeException { return game.onClientThread(client -> { JsonObject out = new JsonObject(); @@ -73,6 +95,7 @@ private JsonElement state(JsonObject args) throws BridgeException { Canvas canvas = client.getCanvas(); out.add("canvas", ints(canvas.getWidth(), canvas.getHeight())); out.addProperty("world", client.getWorld()); + Player player = client.getLocalPlayer(); if (player == null) { out.add("player", JsonNull.INSTANCE); @@ -108,6 +131,7 @@ private JsonElement state(JsonObject args) throws BridgeException { }); } + /** List the widgets that pass the group, text and hidden filters, up to the limit. */ private JsonElement widgets(JsonObject args) throws BridgeException { Integer group = optionalInt(args, "group"); String text = optionalString(args, "text"); @@ -143,13 +167,25 @@ private JsonElement widgets(JsonObject args) throws BridgeException { /** Lists the widgets that have something to read or click: text, a name or actions. */ private static final class WidgetWalk { + /** The widgets collected so far. */ final JsonArray found = new JsonArray(); + + /** Whether the limit cut the walk short. */ boolean truncated; + + /** The interface group to keep, or null to keep every group. */ private final Integer group; + + /** The lower-case text to look for, or null to keep every widget. */ private final String needle; + + /** Whether hidden widgets are walked too. */ private final boolean includeHidden; + + /** The most widgets to collect. */ private final int limit; + /** Create a walk with the given filters. */ WidgetWalk(Integer group, String needle, boolean includeHidden, int limit) { this.group = group; this.needle = needle; @@ -157,15 +193,12 @@ private static final class WidgetWalk { this.limit = limit; } + /** Collect the widget and walk its children, unless it is hidden from this walk or too deep. */ void visit(Widget widget, int depth) { - if (widget == null || truncated || depth > WIDGET_DEPTH_LIMIT) { - return; - } + if (widget == null || truncated || depth > WIDGET_DEPTH_LIMIT) return; boolean hidden = widget.isHidden(); - if (hidden && !includeHidden) { - return; - } + if (hidden && !includeHidden) return; collect(widget, hidden); visitAll(widget.getStaticChildren(), depth); @@ -173,6 +206,7 @@ void visit(Widget widget, int depth) { visitAll(widget.getNestedChildren(), depth); } + /** Add the widget when it passes the filters, or mark the walk truncated once the limit is reached. */ private void collect(Widget widget, boolean hidden) { if (group != null && widget.getId() >>> 16 != group) return; @@ -188,6 +222,7 @@ private void collect(Widget widget, boolean hidden) { found.add(described); } + /** Visit each of the children, one level deeper. */ private void visitAll(Widget[] children, int depth) { if (children == null) return; @@ -196,6 +231,7 @@ private void visitAll(Widget[] children, int depth) { } } + /** Describe the widget. Returns null when it has nothing to read or click, or does not hold the needle. */ private JsonObject describe(Widget widget, boolean hidden) { String text = nullToEmpty(widget.getText()); String name = nullToEmpty(widget.getName()); @@ -208,13 +244,9 @@ private JsonObject describe(Widget widget, boolean hidden) { actions.add(action); } - if (text.isEmpty() && name.isEmpty() && actions.size() == 0) { - return null; - } + if (text.isEmpty() && name.isEmpty() && actions.size() == 0) return null; - if (needle != null && !contains(text) && !contains(name) && !contains(actions.toString())) { - return null; - } + if (needle != null && !contains(text) && !contains(name) && !contains(actions.toString())) return null; Rectangle bounds = widget.getBounds(); JsonObject out = new JsonObject(); @@ -230,11 +262,13 @@ private JsonObject describe(Widget widget, boolean hidden) { return out; } + /** Determine if the haystack holds the needle, ignoring case. */ private boolean contains(String haystack) { return haystack.toLowerCase(Locale.ROOT).contains(needle); } } + /** Read the requested player variables, varbits and client variables. */ private JsonElement vars(JsonObject args) throws BridgeException { int[] varps = optionalInts(args, "varps"); int[] varbits = optionalInts(args, "varbits"); @@ -253,9 +287,11 @@ private JsonElement vars(JsonObject args) throws BridgeException { } private interface VarReader { + /** Read the variable with the given id. */ JsonElement read(int id); } + /** Read each id through the reader, keyed by id. Throws {@code not_found} for an id the client does not have. */ private static JsonObject readVars(String kind, int[] ids, VarReader reader) throws BridgeException { JsonObject out = new JsonObject(); @@ -271,15 +307,14 @@ private static JsonObject readVars(String kind, int[] ids, VarReader reader) thr return out; } + /** Capture the next frame as a PNG that has the size of the canvas. */ private JsonElement screenshot(JsonObject args) throws BridgeException { BufferedImage frame = game.frame(); - int[] canvas = game.onClientThread(client -> new int[] {client.getCanvas().getWidth(), client.getCanvas().getHeight()}); - int width = canvas[0]; - int height = canvas[1]; + Dimension canvas = game.onClientThread(client -> client.getCanvas().getSize()); + int width = canvas.width; + int height = canvas.height; - if (width <= 0 || height <= 0) { - throw new BridgeException("wrong_state", "the canvas has no size yet"); - } + if (width <= 0 || height <= 0) throw new BridgeException("wrong_state", "the canvas has no size yet"); // A high-density display renders more pixels than the canvas has units. Clicks are in canvas // units, so the image is brought to canvas size to make a pixel and a click coordinate the same thing. @@ -309,6 +344,7 @@ private JsonElement screenshot(JsonObject args) throws BridgeException { return out; } + /** Click the canvas at the given coordinates, or at the centre of the given widget. */ private JsonElement click(JsonObject args) throws BridgeException { boolean right = "right".equals(optionalString(args, "button")); String widget = optionalString(args, "widget"); @@ -317,29 +353,15 @@ private JsonElement click(JsonObject args) throws BridgeException { Point point; if (widget != null) { - point = game.onClientThread(client -> centre(visibleBounds(client, widget))); + point = game.onClientThread(client -> centre(visibleWidget(client, widget).getBounds())); } else if (x != null && y != null) { point = new Point(x, y); } else { throw new BridgeException("bad_args", "pass either x and y, or widget"); } - int awtButton = right ? MouseEvent.BUTTON3 : MouseEvent.BUTTON1; - int downMask = right ? InputEvent.BUTTON3_DOWN_MASK : InputEvent.BUTTON1_DOWN_MASK; - game.input(canvas -> { - long now = System.currentTimeMillis(); - canvas.dispatchEvent( - new MouseEvent(canvas, MouseEvent.MOUSE_MOVED, now, 0, point.x, point.y, 0, false)); - - canvas.dispatchEvent( - new MouseEvent(canvas, MouseEvent.MOUSE_PRESSED, now, downMask, point.x, point.y, 1, false, awtButton)); - - canvas.dispatchEvent( - new MouseEvent(canvas, MouseEvent.MOUSE_RELEASED, now + 30, 0, point.x, point.y, 1, false, awtButton)); - - canvas.dispatchEvent( - new MouseEvent(canvas, MouseEvent.MOUSE_CLICKED, now + 30, 0, point.x, point.y, 1, false, awtButton)); - }); + mouse.hover(point.x, point.y); + mouse.click(point.x, point.y, right ? Mouse.Button.RIGHT : Mouse.Button.LEFT); JsonObject out = new JsonObject(); out.addProperty("x", point.x); @@ -348,29 +370,39 @@ private JsonElement click(JsonObject args) throws BridgeException { return out; } - private static Rectangle visibleBounds(Client client, String ref) throws BridgeException { + /** + * Get the referenced widget. Throws {@code bad_args} for a malformed reference and + * {@code not_found} for a widget that does not exist or is not visible. + */ + static Widget visibleWidget(Client client, String ref) throws BridgeException { Matcher matcher = WIDGET_REF.matcher(ref); - if (!matcher.matches()) { - throw new BridgeException("bad_args", "widget must look like \"558:7\" or \"558:7[3]\""); - } + if (!matcher.matches()) throw new BridgeException("bad_args", WIDGET_REF_SHAPE); - Widget widget = client.getWidget(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2))); - if (widget != null && matcher.group(3) != null) { - widget = widget.getChild(Integer.parseInt(matcher.group(3))); - } + int index = matcher.group(3) == null ? -1 : Integer.parseInt(matcher.group(3)); + Widget widget = widget(client, Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), index); - if (widget == null) { - throw new BridgeException("not_found", "widget " + ref + " does not exist"); - } + if (widget == null) throw new BridgeException("not_found", "widget " + ref + " does not exist"); + + if (!isVisible(widget)) throw new BridgeException("not_found", "widget " + ref + " is not visible"); + + return widget; + } + + /** Get the widget of the group and child, or its dynamic child at the index unless it is -1. Null when absent. */ + static Widget widget(Client client, int group, int child, int index) { + Widget widget = client.getWidget(group, child); + return widget == null || index < 0 ? widget : widget.getChild(index); + } + + /** Determine if the widget is shown with some size. */ + static boolean isVisible(Widget widget) { Rectangle bounds = widget.getBounds(); - if (widget.isHidden() || bounds == null || bounds.isEmpty()) { - throw new BridgeException("not_found", "widget " + ref + " is not visible"); - } - return bounds; + return !widget.isHidden() && bounds != null && !bounds.isEmpty(); } + /** Type the text as key events, followed by Enter when asked. */ private JsonElement type(JsonObject args) throws BridgeException { String text = args.get("text").getAsString(); boolean enter = optionalBoolean(args, "enter"); @@ -390,6 +422,7 @@ private JsonElement type(JsonObject args) throws BridgeException { return out; } + /** Dispatch the press, the typed character and the release of one key. */ private static void key(Canvas canvas, int code, char ch) { long now = System.currentTimeMillis(); canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_PRESSED, now, 0, code, ch)); @@ -397,12 +430,12 @@ private static void key(Canvas canvas, int code, char ch) { canvas.dispatchEvent(new KeyEvent(canvas, KeyEvent.KEY_RELEASED, now, 0, code, ch)); } + /** Log in with the given credentials and wait until the client is in the game. */ private JsonElement login(JsonObject args) throws BridgeException { String username = args.get("username").getAsString(); - String password = optionalString(args, "password"); - if (password == null || password.isEmpty()) { - throw new BridgeException("bad_args", "password is required and must not be empty"); - } + String password = args.get("password").getAsString(); + + if (password.isEmpty()) throw new BridgeException("bad_args", "password is required and must not be empty"); long deadline = System.currentTimeMillis() + args.get("wait_ms").getAsInt(); awaitLoaded(deadline); @@ -411,29 +444,36 @@ private JsonElement login(JsonObject args) throws BridgeException { return awaitLoggedIn(deadline); } - /** The bridge connects while the client is still loading, and a login set before the login screen is lost. */ + /** + * Wait until the client has finished starting. Throws {@code timeout} when the deadline passes first. + * The bridge connects while the client is still loading, and a login set before the login screen is lost. + */ private void awaitLoaded(long deadline) throws BridgeException { while (true) { GameState state = game.onClientThread(Client::getGameState); if (state != GameState.STARTING && state != GameState.UNKNOWN) return; - if (System.currentTimeMillis() >= deadline) { - throw new BridgeException( - "timeout", - "the client did not reach the login screen; gameState is " + state - + ". A client that cannot reach the game server stays in this state."); - } + if (System.currentTimeMillis() >= deadline) throw neverLoaded(state); pollAgain("the login screen"); } } + /** Build the timeout for a client that is still in the given state when the wait for the login screen ends. */ + private static BridgeException neverLoaded(GameState state) { + String reached = "the client did not reach the login screen; gameState is " + state + ". "; + String hint = "A client that cannot reach the game server stays in this state."; + + return new BridgeException("timeout", reached + hint); + } + + /** Set the credentials and start the login. Throws {@code wrong_state} unless the client is on the login screen. */ private void submitCredentials(String username, String password) throws BridgeException { game.onClientThread(client -> { GameState state = client.getGameState(); - if (state != GameState.LOGIN_SCREEN) { - throw new BridgeException("wrong_state", "the client is not on the login screen: " + state); - } + String refusal = "the client is not on the login screen: " + state; + + if (state != GameState.LOGIN_SCREEN) throw new BridgeException("wrong_state", refusal); client.setUsername(username); client.setPassword(password); @@ -443,6 +483,10 @@ private void submitCredentials(String username, String password) throws BridgeEx }); } + /** + * Wait until the client is in the game. Throws {@code wrong_state} when the login is refused and + * {@code timeout} when the deadline passes first. + */ private JsonElement awaitLoggedIn(long deadline) throws BridgeException { while (true) { GameState state = game.onClientThread(Client::getGameState); @@ -453,18 +497,20 @@ private JsonElement awaitLoggedIn(long deadline) throws BridgeException { return out; } - if (state == GameState.LOGIN_SCREEN) { - throw new BridgeException("wrong_state", "the login was refused; the client is back on the login screen"); - } + if (state == GameState.LOGIN_SCREEN) throw new BridgeException("wrong_state", LOGIN_REFUSED); - if (System.currentTimeMillis() >= deadline) { - throw new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); - } + if (System.currentTimeMillis() >= deadline) throw neverLoggedIn(state); pollAgain("the login"); } } + /** Build the timeout for a login that is still in the given state when the wait ends. */ + private static BridgeException neverLoggedIn(GameState state) { + return new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); + } + + /** Sleep until the next read of the game state is due. */ private static void pollAgain(String awaited) throws BridgeException { try { Thread.sleep(LOGIN_POLL_MS); @@ -474,16 +520,19 @@ private static void pollAgain(String awaited) throws BridgeException { } } + /** Get the point at the centre of the bounds. */ private static Point centre(Rectangle bounds) { return new Point(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); } - private static String ref(Widget widget) { + /** Get the reference that names the widget, in the shape that a click accepts. */ + static String ref(Widget widget) { String ref = (widget.getId() >>> 16) + ":" + (widget.getId() & 0xFFFF); return widget.getIndex() >= 0 ? ref + "[" + widget.getIndex() + "]" : ref; } + /** Build a JSON array of the values. */ private static JsonArray ints(int... values) { JsonArray out = new JsonArray(); @@ -494,83 +543,59 @@ private static JsonArray ints(int... values) { return out; } + /** Wrap the value as a JSON number. */ private static JsonElement number(int value) { return new JsonPrimitive(value); } + /** Wrap the value as a JSON string, or as JSON null when there is none. */ private static JsonElement string(String value) { return value == null ? JsonNull.INSTANCE : new JsonPrimitive(value); } - private static String nullToEmpty(String value) { + /** Get the value, or an empty string in place of null. */ + static String nullToEmpty(String value) { return value == null ? "" : value; } + /** Get the named argument, or null when it is absent or JSON null. */ private static JsonElement present(JsonObject args, String name) { JsonElement value = args.get(name); return value == null || value.isJsonNull() ? null : value; } - private static Integer optionalInt(JsonObject args, String name) throws BridgeException { + /** Get the named integer argument, or null when it is absent. */ + static Integer optionalInt(JsonObject args, String name) { JsonElement value = present(args, name); - if (value == null) { - return null; - } - - if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber()) { - throw new BridgeException("bad_args", name + " must be an integer"); - } - return value.getAsInt(); + return value == null ? null : value.getAsInt(); } - private static String optionalString(JsonObject args, String name) throws BridgeException { + /** Get the named string argument, or null when it is absent. */ + static String optionalString(JsonObject args, String name) { JsonElement value = present(args, name); - if (value == null) { - return null; - } - - if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) { - throw new BridgeException("bad_args", name + " must be a string"); - } - return value.getAsString(); + return value == null ? null : value.getAsString(); } - private static boolean optionalBoolean(JsonObject args, String name) throws BridgeException { + /** Get the named boolean argument, or false when it is absent. */ + private static boolean optionalBoolean(JsonObject args, String name) { JsonElement value = present(args, name); - if (value == null) { - return false; - } - if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) { - throw new BridgeException("bad_args", name + " must be a boolean"); - } - - return value.getAsBoolean(); + return value != null && value.getAsBoolean(); } - private static int[] optionalInts(JsonObject args, String name) throws BridgeException { + /** Get the named array of integers, or an empty array when it is absent. */ + private static int[] optionalInts(JsonObject args, String name) { JsonElement value = present(args, name); - if (value == null) { - return new int[0]; - } - - if (!value.isJsonArray()) { - throw new BridgeException("bad_args", name + " must be an array of integers"); - } + if (value == null) return new int[0]; JsonArray array = value.getAsJsonArray(); int[] out = new int[array.size()]; for (int i = 0; i < out.length; i++) { - JsonElement item = array.get(i); - if (!item.isJsonPrimitive() || !item.getAsJsonPrimitive().isNumber()) { - throw new BridgeException("bad_args", name + " must be an array of integers"); - } - - out[i] = item.getAsInt(); + out[i] = array.get(i).getAsInt(); } return out; diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java new file mode 100644 index 00000000..ea69f450 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java @@ -0,0 +1,82 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Player; +import net.runelite.api.coords.WorldPoint; + +/** Another player, named by its index in the client's list. Its menu entries carry that index as the identifier. */ +final class PlayerTarget extends Target { + /** The menu actions that perform an option on a player. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.PLAYER_FIRST_OPTION, + MenuAction.PLAYER_SECOND_OPTION, + MenuAction.PLAYER_THIRD_OPTION, + MenuAction.PLAYER_FOURTH_OPTION, + MenuAction.PLAYER_FIFTH_OPTION, + MenuAction.PLAYER_SIXTH_OPTION, + MenuAction.PLAYER_SEVENTH_OPTION, + MenuAction.PLAYER_EIGHTH_OPTION); + + /** The index of the player. */ + private final int index; + + /** The name of the player when it was found. */ + private final String name; + + /** Create the target for the player with the index, as found with the name. */ + PlayerTarget(int index, String name, String option) { + super(option); + this.index = index; + this.name = name; + } + + /** Get the words that name the player. */ + @Override + String label() { + return "player " + index + " (" + name + ")"; + } + + /** Describe the player with its index and name. */ + @Override + JsonObject describe() { + JsonObject out = described("OPPLAYER", "player"); + out.addProperty("index", index); + out.addProperty("name", name); + out.addProperty("option", option); + + return out; + } + + /** Get the convex hull of the player's model, or null while the client does not show it. */ + @Override + Shape shape(Client client) { + Player player = find(client); + + return player == null ? null : player.getConvexHull(); + } + + /** Determine if the entry is this option on the player with this index. */ + @Override + boolean matches(MenuEntry entry, Client client) { + return FAMILY.contains(entry.getType()) && entry.getIdentifier() == index && sameOption(entry); + } + + /** Get the tile the player stands on, or null while the client does not have it. */ + @Override + WorldPoint tile(Client client) { + Player player = find(client); + + return player == null ? null : player.getWorldLocation(); + } + + /** Find the player in the client's list, or null while it is not there. */ + private Player find(Client client) { + return client.getTopLevelWorldView().players().byIndex(index); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java index 4df9b462..f25b1c6e 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java @@ -2,6 +2,14 @@ /** How rsprox wants this client to draw its frames. */ enum Rendering { + /** Frames drawn by the GPU plugin, when the client's profile enables it. */ GPU, - SOFTWARE + + /** Frames drawn without the GPU plugin, which the bridge stops. */ + SOFTWARE; + + /** Get the rendering that the {@code softwareRendering} flag of a welcome asks for. */ + static Rendering fromWire(boolean softwareRendering) { + return softwareRendering ? SOFTWARE : GPU; + } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java new file mode 100644 index 00000000..64d592ad --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java @@ -0,0 +1,107 @@ +package net.rsprox.mcpbridge; + +import java.awt.Polygon; +import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.List; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; + +/** + * Reads of the top-level world view that the entity listing and the interactions share. World + * coordinates are the scene coordinates plus the base of the view, which is what the packets carry. + */ +final class Scenes { + /** The options of a ground item by position. The RuneLite API exposes none, and the client takes with the third. */ + static final String[] GROUND_ITEM_OPTIONS = {null, null, "Take", null, null}; + + /** Not for instances. */ + private Scenes() { + // + } + + /** Determine if the world coordinates lie in the scene that the view has loaded. */ + static boolean isLoaded(WorldView view, int x, int y) { + int sceneX = x - view.getBaseX(); + int sceneY = y - view.getBaseY(); + + return sceneX >= 0 && sceneY >= 0 && sceneX < view.getSizeX() && sceneY < view.getSizeY(); + } + + /** Get the tile at the world coordinates on the plane in view, or null when the scene has none there. */ + static Tile tile(WorldView view, int x, int y) { + if (!isLoaded(view, x, y)) return null; + + return view.getScene().getTiles()[view.getPlane()][x - view.getBaseX()][y - view.getBaseY()]; + } + + /** Get the objects on the tile that can have options: its game, wall, ground and decorative objects. */ + static List objects(Tile tile) { + List out = new ArrayList<>(); + GameObject[] gameObjects = tile.getGameObjects(); + + for (GameObject object : gameObjects == null ? new GameObject[0] : gameObjects) { + if (object != null) out.add(object); + } + + if (tile.getWallObject() != null) out.add(tile.getWallObject()); + + if (tile.getGroundObject() != null) out.add(tile.getGroundObject()); + + if (tile.getDecorativeObject() != null) out.add(tile.getDecorativeObject()); + + return out; + } + + /** + * Get the scene tile that an option on the object names: the minimum tile of a game object, which + * may cover several, and the tile it stands on for any other object. + */ + static Point origin(TileObject object, Tile tile) { + return object instanceof GameObject ? ((GameObject) object).getSceneMinLocation() : tile.getSceneLocation(); + } + + /** Get the definition that the client shows for the object id, or null while it shows none. */ + static ObjectComposition shown(Client client, int id) { + ObjectComposition composition = client.getObjectDefinition(id); + + return composition.getImpostorIds() == null ? composition : composition.getImpostor(); + } + + /** Get the names of the options that are offered, in order. */ + static List offered(String[] options) { + List out = new ArrayList<>(); + + for (String option : options == null ? new String[0] : options) { + if (option != null && !option.isEmpty()) out.add(option); + } + + return out; + } + + /** Get the part of the canvas that shows the 3D scene. */ + static Rectangle viewport(Client client) { + return new Rectangle( + client.getViewportXOffset(), + client.getViewportYOffset(), + client.getViewportWidth(), + client.getViewportHeight()); + } + + /** Get the outline of the scene tile on the canvas, or null while the tile is not in front of the camera. */ + static Polygon tilePolygon(Client client, WorldView view, int sceneX, int sceneY) { + return Perspective.getCanvasTilePoly(client, LocalPoint.fromScene(sceneX, sceneY, view)); + } + + /** Write the world tile as its coordinates in brackets. */ + static String words(int x, int y) { + return "(" + x + ", " + y + ")"; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java new file mode 100644 index 00000000..502abc57 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java @@ -0,0 +1,65 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Shape; +import net.runelite.api.Client; +import net.runelite.api.MenuEntry; +import net.runelite.api.coords.WorldPoint; + +/** + * One thing in the client that an option is performed on, named by its identity so that it can be + * found again in the client's own state on every attempt. All reads happen on the client thread. + */ +abstract class Target { + /** The option to perform, as the caller wrote it. */ + final String option; + + /** Create a target on which the option is to be performed. */ + Target(String option) { + this.option = option; + } + + /** Get the words that name the target in a message. */ + abstract String label(); + + /** Describe the target for the result: the prefix of the packet to expect, its kind, identity and option. */ + abstract JsonObject describe(); + + /** Get the clickable outline of the target on the canvas, or null while no part of it can be clicked. */ + abstract Shape shape(Client client); + + /** Determine if the menu entry performs this target's option on this very target. */ + abstract boolean matches(MenuEntry entry, Client client); + + /** Get the world tile to turn the camera toward, or null for a target that is not in the world. */ + abstract WorldPoint tile(Client client); + + /** Get the part of the canvas within which the target can be clicked: the 3D view unless overridden. */ + Rectangle area(Client client) { + return Scenes.viewport(client); + } + + /** Get the canvas point to aim at on the given attempt, or null while no part of the target is on screen. */ + Point aim(Client client, int attempt) { + Shape shape = shape(client); + if (shape == null) return null; + + return Aiming.point(shape, area(client), attempt); + } + + /** Determine if the entry's option is exactly the option, ignoring case and colour tags. */ + boolean sameOption(MenuEntry entry) { + return option.equalsIgnoreCase(Offer.untagged(entry.getOption())); + } + + /** Start the description with the packet prefix, the kind and, for the result's order, nothing else yet. */ + static JsonObject described(String expect, String kind) { + JsonObject out = new JsonObject(); + out.addProperty("expect", expect); + out.addProperty("target", kind); + + return out; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java new file mode 100644 index 00000000..2051c321 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java @@ -0,0 +1,305 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.NPCComposition; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Player; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.TileItem; +import net.runelite.api.TileObject; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; + +/** + * Finds the target that the arguments of an interaction name in the client's own state, and checks + * that it offers the option. The client would send a packet for a target or an option it does not + * have, so both are refused before anything is clicked. + */ +final class Targets { + /** The arguments that each kind of target needs. */ + private static final Map> REQUIRED = Map.of( + "npc", List.of("index", "option"), + "object", List.of("id", "x", "y", "option"), + "ground_item", List.of("id", "x", "y", "option"), + "player", List.of("index", "option"), + "widget", List.of("widget", "option"), + "dialog", List.of("option"), + "tile", List.of("x", "y")); + + /** The option that every NPC, object and ground item has after its own five. */ + private static final String EXAMINE = "Examine"; + + /** The position of the examine option, after the five options of the target. */ + private static final int EXAMINE_POSITION = 5; + + /** The number of options a player can have. */ + private static final int PLAYER_OPTIONS = 8; + + /** The option the client offers on a widget that continues a dialog. */ + private static final String CONTINUE = "Continue"; + + /** The text of the widget that continues a dialog. */ + private static final String CONTINUE_TEXT = "Click here to continue"; + + /** The deepest level of nested widgets that a search descends to. */ + private static final int WIDGET_DEPTH_LIMIT = 12; + + /** Not for instances. */ + private Targets() { + // + } + + /** A target as found, with the options the client says it has. */ + private static final class Found { + /** The target. */ + final Target target; + + /** The options the target offers, in the client's order. */ + final List options; + + /** Create the record of a found target. */ + Found(Target target, List options) { + this.target = target; + this.options = options; + } + } + + /** Check that the kind of target has its arguments. Throws {@code bad_args} for the first that is missing. */ + static void requireArguments(JsonObject args) throws BridgeException { + require(args, name -> true); + } + + /** Check that the kind of target has the arguments that name it, whatever the option. Throws {@code bad_args}. */ + static void requireIdentity(JsonObject args) throws BridgeException { + require(args, name -> !name.equals("option")); + } + + /** Check that the kind of target has those of its arguments that pass the test. Throws {@code bad_args}. */ + private static void require(JsonObject args, Predicate wanted) throws BridgeException { + String kind = args.get("target").getAsString(); + List required = REQUIRED.get(kind); + + if (required == null) throw new BridgeException("bad_args", "unknown target '" + kind + "'"); + + for (String name : required) { + JsonElement value = args.get(name); + boolean missing = wanted.test(name) && (value == null || value.isJsonNull()); + + if (missing) throw new BridgeException("bad_args", "target " + kind + " needs '" + name + "'"); + } + } + + /** + * Find the target that the arguments name and check that it offers the option. Throws + * {@code not_found} for a target the client does not have or an option it does not offer. + */ + static Target resolve(Client client, JsonObject args) throws BridgeException { + Found found = find(client, args); + Target target = found.target; + String refusal = target.label() + " has no option '" + target.option + "'. It offers: " + found.options; + + if (found.options.stream().noneMatch(target.option::equalsIgnoreCase)) throw notFound(refusal); + + return target; + } + + /** + * Find the target that the arguments name, whatever its options. Throws {@code not_found} when the + * client does not have it. + */ + static Target locate(Client client, JsonObject args) throws BridgeException { + return find(client, args).target; + } + + /** Find the target of the kind the arguments name, with the options the client says it has. */ + private static Found find(Client client, JsonObject args) throws BridgeException { + String option = Ops.nullToEmpty(Ops.optionalString(args, "option")); + + switch (args.get("target").getAsString()) { + case "npc": + return npc(client, args.get("index").getAsInt(), option); + case "player": + return player(client, args.get("index").getAsInt(), option); + case "widget": + return widget(client, args.get("widget").getAsString(), option); + case "dialog": + return dialog(client, option); + case "object": + return object(client, args.get("id").getAsInt(), tile(client, args), option); + case "ground_item": + return groundItem(client, args.get("id").getAsInt(), tile(client, args), option); + default: + return new Found(new TileTarget(tile(client, args)), List.of(TileTarget.WALK_HERE)); + } + } + + /** Get the world tile that the arguments name. Throws {@code not_found} for one outside the loaded scene. */ + private static WorldPoint tile(Client client, JsonObject args) throws BridgeException { + int x = args.get("x").getAsInt(); + int y = args.get("y").getAsInt(); + WorldView view = client.getTopLevelWorldView(); + String refusal = "tile " + Scenes.words(x, y) + " is not in the loaded scene"; + + if (!Scenes.isLoaded(view, x, y)) throw notFound(refusal); + + return new WorldPoint(x, y, view.getPlane()); + } + + /** Find the NPC with the index in the client's list of NPCs. */ + private static Found npc(Client client, int index, String option) throws BridgeException { + NPC npc = client.getTopLevelWorldView().npcs().byIndex(index); + NPCComposition composition = npc == null ? null : npc.getTransformedComposition(); + + if (composition == null) throw notFound("no npc with index " + index + " is in the client's view"); + + String name = Ops.nullToEmpty(composition.getName()); + + return new Found(new NpcTarget(index, npc.getId(), name, option), withExamine(composition.getActions())); + } + + /** Find the player with the index in the client's list of players. */ + private static Found player(Client client, int index, String option) throws BridgeException { + Player player = client.getTopLevelWorldView().players().byIndex(index); + if (player == null) throw notFound("no player with index " + index + " is in the client's view"); + + String[] options = Arrays.copyOf(client.getPlayerOptions(), PLAYER_OPTIONS); + + return new Found(new PlayerTarget(index, Ops.nullToEmpty(player.getName()), option), Scenes.offered(options)); + } + + /** + * Find the visible widget with the reference. Besides its own ops, the client may offer to continue + * a dialog on it. + */ + private static Found widget(Client client, String ref, String option) throws BridgeException { + Widget widget = Ops.visibleWidget(client, ref); + List options = new ArrayList<>(Scenes.offered(widget.getActions())); + options.add(CONTINUE); + + return new Found(new WidgetTarget(ref, widget.getId(), widget.getIndex(), option), options); + } + + /** + * Find the widget of the open dialog that the option names: the one that continues it for + * "continue", or the numbered or worded choice of a dialog with options. + */ + private static Found dialog(Client client, String option) throws BridgeException { + if (option.equalsIgnoreCase(CONTINUE)) return continueWidget(client); + + Widget options = client.getWidget(ComponentID.DIALOG_OPTION_OPTIONS); + Widget[] choices = options == null || options.isHidden() ? null : options.getDynamicChildren(); + List texts = new ArrayList<>(); + + for (Widget choice : choices == null ? new Widget[0] : choices) { + String text = Ops.nullToEmpty(choice.getText()); + boolean chosen = text.equalsIgnoreCase(option) || Integer.toString(choice.getIndex()).equals(option); + + if (chosen && Ops.isVisible(choice)) return found(choice); + + if (!text.isEmpty() && choice.getIndex() > 0) texts.add(choice.getIndex() + ". " + text); + } + + throw notFound("no dialog option '" + option + "' is shown. It offers: " + texts); + } + + /** Find the visible widget that continues a dialog. Throws {@code not_found} while no dialog waits. */ + private static Found continueWidget(Client client) throws BridgeException { + for (Widget root : client.getWidgetRoots()) { + Widget widget = firstVisible(root, Targets::continues, 0); + if (widget != null) return found(widget); + } + + throw notFound("no dialog is waiting to be continued"); + } + + /** Determine if the widget is the one that continues a dialog, by its text. */ + private static boolean continues(Widget widget) { + return CONTINUE_TEXT.equalsIgnoreCase(Ops.nullToEmpty(widget.getText()).trim()); + } + + /** Build the found record of a dialog widget, which the client offers to continue. */ + private static Found found(Widget widget) { + WidgetTarget target = new WidgetTarget(Ops.ref(widget), widget.getId(), widget.getIndex(), CONTINUE); + + return new Found(target, List.of(CONTINUE)); + } + + /** Find the first visible widget under the root, itself included, that passes the test. */ + private static Widget firstVisible(Widget root, Predicate test, int depth) { + if (root == null || root.isHidden() || depth > WIDGET_DEPTH_LIMIT) return null; + + if (test.test(root)) return root; + + Widget[] nested = root.getNestedChildren(); + + for (Widget[] children : Arrays.asList(root.getStaticChildren(), root.getDynamicChildren(), nested)) { + for (Widget child : children == null ? new Widget[0] : children) { + Widget found = firstVisible(child, test, depth + 1); + if (found != null) return found; + } + } + + return null; + } + + /** Find the object that has the id, or shows as it, on the world tile. */ + private static Found object(Client client, int id, WorldPoint at, String option) throws BridgeException { + WorldView view = client.getTopLevelWorldView(); + Tile tile = Scenes.tile(view, at.getX(), at.getY()); + + for (TileObject object : tile == null ? List.of() : Scenes.objects(tile)) { + ObjectComposition shown = Scenes.shown(client, object.getId()); + if (shown == null || (object.getId() != id && shown.getId() != id)) continue; + + Point origin = Scenes.origin(object, tile); + int x = view.getBaseX() + origin.getX(); + int y = view.getBaseY() + origin.getY(); + WorldPoint world = new WorldPoint(x, y, at.getPlane()); + ObjectTarget target = new ObjectTarget(object.getId(), Ops.nullToEmpty(shown.getName()), world, option); + + return new Found(target, withExamine(shown.getActions())); + } + + throw notFound("no object " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); + } + + /** Find the item with the id that lies on the world tile. */ + private static Found groundItem(Client client, int id, WorldPoint at, String option) throws BridgeException { + Tile tile = Scenes.tile(client.getTopLevelWorldView(), at.getX(), at.getY()); + List items = tile == null ? null : tile.getGroundItems(); + + for (TileItem item : items == null ? List.of() : items) { + if (item.getId() != id) continue; + + String name = Ops.nullToEmpty(client.getItemDefinition(id).getName()); + + return new Found(new GroundItemTarget(id, name, at, option), withExamine(Scenes.GROUND_ITEM_OPTIONS)); + } + + throw notFound("no ground item " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); + } + + /** Get the options offered among the five of a target, followed by the examine option that each such target has. */ + private static List withExamine(String[] options) { + String[] out = Arrays.copyOf(options == null ? new String[0] : options, EXAMINE_POSITION + 1); + out[EXAMINE_POSITION] = EXAMINE; + + return Scenes.offered(out); + } + + /** Build the failure for something the client does not have. */ + private static BridgeException notFound(String message) { + return new BridgeException("not_found", message); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/TileTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/TileTarget.java new file mode 100644 index 00000000..a6dde199 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/TileTarget.java @@ -0,0 +1,72 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Point; +import net.runelite.api.Tile; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** + * A world tile to walk to. The client's walk entry names no tile: it walks to the tile the mouse + * hovers, so the entry matches only while that is this tile. + */ +final class TileTarget extends Target { + /** The option the client offers on a tile. */ + static final String WALK_HERE = "Walk here"; + + /** The world tile. */ + private final WorldPoint at; + + /** Create the target for walking to the world tile. */ + TileTarget(WorldPoint at) { + super(WALK_HERE); + this.at = at; + } + + /** Get the words that name the tile. */ + @Override + String label() { + return "tile " + Scenes.words(at.getX(), at.getY()); + } + + /** Describe the tile with its world coordinates. */ + @Override + JsonObject describe() { + JsonObject out = described("MOVE_GAMECLICK", "tile"); + out.addProperty("x", at.getX()); + out.addProperty("y", at.getY()); + + return out; + } + + /** Get the outline of the tile, or null while it is not in front of the camera. */ + @Override + Shape shape(Client client) { + WorldView view = client.getTopLevelWorldView(); + if (!Scenes.isLoaded(view, at.getX(), at.getY())) return null; + + return Scenes.tilePolygon(client, view, at.getX() - view.getBaseX(), at.getY() - view.getBaseY()); + } + + /** Determine if the entry is the walk entry while the client's hovered tile is this tile. */ + @Override + boolean matches(MenuEntry entry, Client client) { + if (entry.getType() != MenuAction.WALK) return false; + + WorldView view = client.getTopLevelWorldView(); + Tile hovered = view.getSelectedSceneTile(); + Point wanted = new Point(at.getX() - view.getBaseX(), at.getY() - view.getBaseY()); + + return hovered != null && hovered.getSceneLocation().equals(wanted); + } + + /** Get the world tile. */ + @Override + WorldPoint tile(Client client) { + return at; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java new file mode 100644 index 00000000..46f06277 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java @@ -0,0 +1,97 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Canvas; +import java.awt.Rectangle; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.Widget; + +/** + * An interface component, named by its packed id and, for a dynamic child, its index. Its menu + * entries carry the id as the second parameter and the child index as the first. + */ +final class WidgetTarget extends Target { + /** The menu actions that perform an option on an interface component. */ + private static final Set FAMILY = EnumSet.of( + MenuAction.CC_OP, + MenuAction.CC_OP_LOW_PRIORITY, + MenuAction.WIDGET_CONTINUE, + MenuAction.WIDGET_TYPE_1, + MenuAction.WIDGET_TYPE_4, + MenuAction.WIDGET_TYPE_5, + MenuAction.WIDGET_CLOSE, + MenuAction.WIDGET_FIRST_OPTION, + MenuAction.WIDGET_SECOND_OPTION, + MenuAction.WIDGET_THIRD_OPTION, + MenuAction.WIDGET_FOURTH_OPTION, + MenuAction.WIDGET_FIFTH_OPTION); + + /** The reference as the caller wrote it, or as the dialog was resolved to. */ + private final String ref; + + /** The packed id of the widget. */ + private final int id; + + /** The index of the dynamic child, or -1 for a static widget. */ + private final int index; + + /** Create the target for the widget with the packed id and child index, referred to by the ref. */ + WidgetTarget(String ref, int id, int index, String option) { + super(option); + this.ref = ref; + this.id = id; + this.index = index; + } + + /** Get the words that name the widget. */ + @Override + String label() { + return "widget " + ref; + } + + /** Describe the widget by its reference. */ + @Override + JsonObject describe() { + JsonObject out = described("IF_BUTTON", "widget"); + out.addProperty("widget", ref); + out.addProperty("option", option); + + return out; + } + + /** Get the bounds of the widget, or null while it is not visible. */ + @Override + Shape shape(Client client) { + Widget widget = Ops.widget(client, id >>> 16, id & 0xFFFF, index); + + return widget == null || !Ops.isVisible(widget) ? null : widget.getBounds(); + } + + /** Determine if the entry is this option on this widget, and on this child for a dynamic one. */ + @Override + boolean matches(MenuEntry entry, Client client) { + boolean sameWidget = entry.getParam1() == id && (index < 0 || entry.getParam0() == index); + + return FAMILY.contains(entry.getType()) && sameWidget && sameOption(entry); + } + + /** Get no tile: an interface is not in the world, so the camera cannot bring it into view. */ + @Override + WorldPoint tile(Client client) { + return null; + } + + /** Get the whole canvas, since an interface lies outside the 3D view. */ + @Override + Rectangle area(Client client) { + Canvas canvas = client.getCanvas(); + + return new Rectangle(0, 0, canvas.getWidth(), canvas.getHeight()); + } +} From 9bdfe4b57772af1a58867bd5f49c7d5336638190 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 21:09:51 +0200 Subject: [PATCH 16/72] fix(mcp): tie each interaction to its click and wait in client cycles Findings from a review of the interaction loop. The loop counted its waits in rendered frames. With an unlocked frame rate several frames pass inside one 20 ms client cycle, so the click guard was disarmed before the client processed the click, the click went through unjudged, and the loop clicked again. Waits are now counted in the client's game cycles. A left click that the client resolves to another option, as happens when the client reorders its menu or a plugin swaps entries, is cancelled by the guard. The loop now switches to the right-click menu for the rest of that interaction, where it used to repeat the left click until the deadline. Ops that produce input hold one lock for their whole run, so two calls on one session no longer cancel each other's clicks. The server proves an interaction by finding the client's mouse click for the pressed point and taking the action packet that follows it. It used to take the first packet with the expected name, which could belong to an earlier action. The walk check no longer compares coordinates in the packet text, which are template coordinates inside an instance. A widget option that the client handles without sending a packet returns a note, and a close button is proven by CLOSE_MODAL. On a login that is not being decoded the tool returns at once and says so. An option whose text carries a colour tag can be matched. Waiting for a frame no longer reads back the framebuffer. List arguments are checked against their allowed values. Also from the review: hooks that existed only for deleted tests are removed, four docblocks that did not match their code are corrected, lists typed on both sides of the bridge have one source each, and the plugin's internal expect field no longer appears in results. The previous commit said it removed only the tests that ran against a faked game. It also removed six test files that did not use the fake. This adds ten feature tests for the tool calls, each driven over HTTP with a fake plugin socket and no model of the game. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 72 ++-- mcp/build.gradle.kts | 6 - mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 2 +- .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 51 +-- .../kotlin/net/rsprox/mcp/bridge/BridgeJar.kt | 39 +- .../net/rsprox/mcp/packets/PacketLog.kt | 82 ++-- .../net/rsprox/mcp/packets/PacketTap.kt | 5 +- .../net/rsprox/mcp/packets/Unfiltered.kt | 2 + .../net/rsprox/mcp/server/InteractionProof.kt | 130 +++++++ .../net/rsprox/mcp/server/McpHttpServer.kt | 12 +- .../net/rsprox/mcp/server/SentPacket.kt | 64 --- .../net/rsprox/mcp/server/TargetKind.kt | 54 +++ .../kotlin/net/rsprox/mcp/server/Tools.kt | 76 ++-- .../mcp/session/ProxyServiceLauncher.kt | 11 +- .../kotlin/net/rsprox/mcp/session/Session.kt | 17 +- .../net/rsprox/mcp/session/SessionManager.kt | 59 +-- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 8 +- .../net/rsprox/mcp/bridge/FakePlugin.kt | 22 +- .../net/rsprox/mcp/server/ToolCallsTest.kt | 364 ++++++++++++++++++ .../net/rsprox/mcp/session/SessionFixtures.kt | 22 +- .../java/net/rsprox/mcpbridge/Aiming.java | 2 +- .../java/net/rsprox/mcpbridge/Camera.java | 82 ++-- .../java/net/rsprox/mcpbridge/CameraTurn.java | 10 +- .../java/net/rsprox/mcpbridge/ClickGuard.java | 87 ++++- .../java/net/rsprox/mcpbridge/Entities.java | 25 +- .../java/net/rsprox/mcpbridge/GameAccess.java | 44 ++- .../rsprox/mcpbridge/GroundItemTarget.java | 21 +- .../net/rsprox/mcpbridge/Interaction.java | 156 +++++--- .../main/java/net/rsprox/mcpbridge/Mouse.java | 21 +- .../java/net/rsprox/mcpbridge/NpcTarget.java | 12 +- .../net/rsprox/mcpbridge/ObjectTarget.java | 19 +- .../main/java/net/rsprox/mcpbridge/Offer.java | 91 +++-- .../main/java/net/rsprox/mcpbridge/Ops.java | 94 +++-- .../net/rsprox/mcpbridge/PlayerTarget.java | 16 +- .../java/net/rsprox/mcpbridge/Scenes.java | 10 +- .../java/net/rsprox/mcpbridge/Target.java | 9 +- .../java/net/rsprox/mcpbridge/Targets.java | 225 ++++++----- .../net/rsprox/mcpbridge/WidgetTarget.java | 24 +- 38 files changed, 1416 insertions(+), 630 deletions(-) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/server/InteractionProof.kt delete mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/server/TargetKind.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt diff --git a/mcp/README.md b/mcp/README.md index 5861f0be..6e7f83f7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -90,15 +90,13 @@ it runs, whatever the clients do. ## Tools The tools come in three groups. The `session_*` tools launch a client for a target, stop it and list -the sessions. `packets_read` reads the decoded packets of a session after a cursor, and can wait for +the sessions. `session_start` gives the launcher of a client 180 seconds to complete its handshake. +When the launcher takes longer, the server abandons the launch and refuses further launches until it +is restarted. `packets_read` reads the decoded packets of a session after a cursor, and can wait for the first packet that matches. The `client_*` tools look at the client and drive it: they read its state, widgets and variables, take a screenshot, log in, click and type, list what is near the player and act on it. -`session_start` gives the launcher of a client 180 seconds to complete its handshake. When the -launcher takes longer, the server abandons the launch and refuses further launches until it is -restarted. - The server describes each tool itself. `tools/list` returns every tool with its description and the schema of its arguments: @@ -106,11 +104,6 @@ schema of its arguments: curl -s http://127.0.0.1:43580/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` -`session` is optional on every tool while only one session exists. - -Every `client_*` result carries `cursor`, the packet cursor taken just before the call. Pass it as -`after` to `packets_read` to see only what the action caused. - Each packet is one line: ` L T `. `C` is client to server, `S` is server to client, and `P` is a marker that rsprox adds (`CLIENT_LAUNCHED`, `CLIENT_CONNECTED`, `CLIENT_EXITED`, `LOGIN`, `LOGOUT`). The key codes of typed text are zeroed in the recorded packets, as @@ -128,18 +121,29 @@ tile. `client_interact` performs one of the listed options, or `Examine` on an NPC, an object or a ground item, the way a player does. It finds the target in the client's own lists, moves the real mouse onto it, lets the client build the menu for what is under the mouse, and clicks: a left click when the -option is the default one, otherwise a right click and a click on the option's row. The server -receives exactly what a player's interaction sends, mouse packets included. The client decides what a -click means, so a target that moves after the mouse was aimed would get the wrong click. The plugin -sees the click the client is about to perform, cancels it before anything is sent when it is not the -intended one, and aims again, within the game tick. When no part of the target is on screen it turns -the camera toward the target and keeps trying for up to six seconds. +option looks like the default one, otherwise a right click and a click on the option's row. The +server receives exactly what a player's interaction sends, mouse packets included. The client decides +what a click means, so a target that moves after the mouse was aimed would get the wrong click. The +plugin sees the click the client is about to perform, cancels it before anything is sent when it is +not the intended one, and aims again, within the game tick. Only the client knows which option a left +click performs, because it sorts its menu late and a plugin such as the menu entry swapper can +reorder it. When a left click turns out to perform another option, the plugin uses the right-click +menu for the rest of the call and says so in `tried`. When no part of the target is on screen it +turns the camera toward the target and keeps trying for up to six seconds. + +Only one call drives the mouse and the keyboard at a time. `client_interact`, `client_click`, +`client_type` and a `client_camera` call that turns the camera take turns, and the calls that only +read run beside them. The call first checks that the client has the target and that the target offers the option, and fails with `not_found` when it does not. It then waits for the packet that the client sends and returns its line as `packet`, with `attempts`, the number of aims it took, `tick`, the client's tick count when the click went through, `tried`, the last attempts in words, and `camera` when it turned -the camera. +the camera. `proof` says how the packet was tied to this call. Packets reach the log up to a tick +late, so a packet that an earlier action caused can arrive after the call began. The server therefore +first finds the client's own `EVENT_MOUSE_CLICK` record of the press, by its canvas coordinates, and +takes the first matching packet after it. `proof` is then `click`. When no such record arrives within +three seconds, it takes the first matching packet after the cursor, and `proof` is `prefix`. ``` 1. session_start {"target":"My Server"} @@ -154,24 +158,29 @@ the camera. "truncated":false,"cursor":612} 4. client_interact {"target":"npc","index":0,"option":"Talk-to"} - -> {"expect":"OPNPC","target":"npc","index":0,"id":3308,"name":"Gielinor Guide","option":"Talk-to", + -> {"target":"npc","index":0,"id":3308,"name":"Gielinor Guide","option":"Talk-to", "attempts":1,"tick":41,"tried":["left-clicked Talk-to Gielinor Guide at (312, 171); the client performed Talk-to Gielinor Guide"],"cursor":613, - "packet":"617 L1 T41 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, ...)"} + "packet":"617 L1 T41 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, ...)","proof":"click"} ``` The `packet` in step 4 is the proof that the client sent the action. The two packets before it are the mouse click and the mouse movement that the client sends for any click. When no such packet -arrives within three seconds, the call fails, because the client dropped the action without a word. -The error names the likely cause: an NPC or a player that left the client's view, or a widget op -that the server has not enabled. What the server answered is in `packets_read {"after":613}`. +arrives within three seconds for a target in the game world, the call fails, because the client +dropped the action without a word. The error names the likely cause, such as an NPC or a player that +left the client's view. A widget is different, because the client handles some widget options by +itself and sends nothing for them. The call then succeeds with `"packet":null` and a `note` that says +so. A login that the proxy has no decoder for has no packets in the log at all, so every interaction +of that login returns `"packet":null` with a `note` that it could not be confirmed. What the server +answered is in `packets_read {"after":613}`. Walking is the same interaction with the option `Walk here` on a tile: `client_interact {"target":"tile","x":3096,"y":3107}` clicks the tile and is proven by the `MOVE_GAMECLICK` packet for it. A dialog is driven through its widgets: `{"target":"dialog","option":"continue"}` clicks the widget that continues it, and `{"target":"dialog","option":"2"}` or `{"target":"dialog","option":"Yes"}` clicks a choice by its -number or its text. Both are proven by `RESUME_PAUSEBUTTON`. +number or its text. Both are proven by `RESUME_PAUSEBUTTON`. A button of an interface is proven by its +`IF_BUTTON` packet, and a close button by `CLOSE_MODAL`. When the client never offers the option within the deadline, about three ticks, the call fails with `wrong_state` and says what the client offered at the last attempt instead, such as @@ -248,9 +257,12 @@ log. The client comes back on new ports, and new packets are marked with the nex ## Check it against a live client -The automated tests cover the packet log, the MCP endpoint and the session lifecycle. They do not run -a game client, so what the client does is checked by hand. Run this after a RuneLite update, a game -revision change, or a change to the plugin. Each step names what proves it. +The automated tests cover the packet log, the endpoint's HTTP and JSON-RPC handling, the tool calls +as an MCP client makes them, the session failure paths and the bridge handshake. They do not run a game client, so what the client does is +checked by hand. Run this after a RuneLite update, a game revision change, or a change to the plugin. +Each step names what proves it, so a step that fails names what changed. A tool that reports a +dropped action, or lists what the client offered instead, is working as intended. Read its message +before suspecting the tool. Start the server and a session against a target you can log in to, with a new account: @@ -258,8 +270,9 @@ Start the server and a session against a target you can log in to, with a new ac 2. `client_login` returns `"gameState":"LOGGED_IN"`, and `packets_read {"origin":"proxy"}` shows a `LOGIN` row. `packets_read {"limit":20}` shows packets of tick 0, with named values. 3. `client_screenshot` returns an image whose width and height equal `canvas` in `client_state`. -4. `client_interact` on an interface button returns a `packet` line. `packets_read` from the returned - cursor shows an `EVENT_MOUSE_CLICK` row before the button packet, so the click was a real one. +4. `client_interact` on an interface button returns a `packet` line and `"proof":"click"`. + `packets_read` from the returned cursor shows an `EVENT_MOUSE_CLICK` row before the button packet, + so the click was a real one. 5. `client_type` into a prompt is followed by a packet that carries the typed text. 6. `client_entities` lists the NPCs and objects that the screenshot shows, with their options. 7. `client_interact` with the default option of an NPC that walks around returns its `OPNPC` packet. @@ -275,6 +288,3 @@ Start the server and a session against a target you can log in to, with a new ac shows the turned view. 13. Killing the client process makes `session_list` show `stopped` with the reason `client exited`, and `session_start` with the same `session` brings it back with the earlier packets still readable. - -A step that fails names what changed. A tool that reports a dropped action or lists what the client -offered instead is working as intended; read its message before suspecting the tool. diff --git a/mcp/build.gradle.kts b/mcp/build.gradle.kts index 8549693c..e11d0af3 100644 --- a/mcp/build.gradle.kts +++ b/mcp/build.gradle.kts @@ -4,7 +4,6 @@ val bridgePlugin: Configuration by configurations.creating { isTransitive = false } -// The same repository as in runelite/rsprox-mcp-bridge/build.gradle.kts, for the client the tests run against. dependencies { bridgePlugin(project(":runelite:rsprox-mcp-bridge")) implementation(projects.proxy) @@ -17,11 +16,6 @@ dependencies { implementation(rootProject.libs.inline.logger) } -tasks.test { - // Code under test resolves ~/.rsprox from this property, so no test reads or writes the real one. - systemProperty("user.home", temporaryDir.absolutePath) -} - tasks.processResources { from(bridgePlugin) { rename { "rsprox-mcp-bridge.jar" } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 54e9644b..0a2f7478 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -124,7 +124,7 @@ public class McpCommand : CliktCommand(name = "mcp") { * Run the startup steps in [body], and close what they added to the list, newest first, when one of them fails. * The failure is passed on. */ -internal fun closingOnFailure(body: (opened: MutableList) -> T): T { +private fun closingOnFailure(body: (opened: MutableList) -> T): T { val opened = ArrayList() try { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index a2f308a2..ae626bae 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -46,10 +46,6 @@ public enum class Rendering { /** Frames drawn without the GPU plugin, which the bridge plugin stops. */ SOFTWARE, - ; - - /** Get the `softwareRendering` flag that tells the plugin of this rendering in a welcome. */ - internal fun toWire(): Boolean = this == SOFTWARE } /** @@ -61,7 +57,7 @@ public class BridgeHub( /** The file through which a plugin finds this hub. */ private val rendezvous: Path, /** The rendering that every welcomed client is told to use. */ - private val rendering: Rendering = Rendering.GPU, + private val rendering: Rendering, ) : AutoCloseable { /** The listeners that wait for a client, by the HTTP port of its launch. */ private val expected = ConcurrentHashMap() @@ -173,13 +169,7 @@ public class BridgeHub( // Only a caller that holds the token may end a launch, so a stray local connection cannot stop a session. if (!tokenMatches(hello.get("token"))) return reject(socket, writer, "bad token") - if (hello.get("hello")?.asInt() != PROTOCOL) { - val refusal = "bridge protocol ${hello.get("hello")} is not supported; the installed plugin jar is stale" - reject(socket, writer, refusal) - expected[httpPort]?.onRejected(refusal) - - return - } + if (hello.get("hello")?.asInt() != PROTOCOL) return rejectStale(socket, writer, hello.get("hello"), httpPort) val listener = expected.remove(httpPort) if (listener == null) return reject(socket, writer, "no session expects httpPort $httpPort") @@ -192,25 +182,38 @@ public class BridgeHub( } // The welcome goes out before the session can see the link, so no request can overtake it. - writer.line( - MAPPER - .createObjectNode() - .put("welcome", PROTOCOL) - .put("session", listener.session) - .put("softwareRendering", rendering.toWire()), - ) + writer.line(welcome(listener)) - if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) { - socket.close() - - return - } + if (!listener.onHello(link, hello.get("pid")?.asLong() ?: -1)) return socket.close() links += link link.start() logger.info { "Bridge connected for session ${listener.session} (httpPort $httpPort)" } } + /** Build the welcome that tells the plugin its session and the rendering to use. */ + private fun welcome(listener: BridgeListener): JsonNode = + MAPPER + .createObjectNode() + .put("welcome", PROTOCOL) + .put("session", listener.session) + .put("softwareRendering", rendering == Rendering.SOFTWARE) + + /** + * Reject a plugin that speaks another protocol than this hub, and tell the session that waits for + * its client, which will not dial again. + */ + private fun rejectStale( + socket: Socket, + writer: Writer, + protocol: JsonNode?, + httpPort: Int, + ) { + val refusal = "bridge protocol $protocol is not supported; the installed plugin jar is stale" + reject(socket, writer, refusal) + expected[httpPort]?.onRejected(refusal) + } + /** Tell the plugin why it is not welcome and close its connection. */ private fun reject( socket: Socket, diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt index 2c35bdd4..6f9075a1 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt @@ -6,34 +6,16 @@ import java.nio.file.Path import java.nio.file.StandardCopyOption /** Installs the embedded bridge plugin jar into the sideload directory of a target's client. */ -public class BridgeJar internal constructor( +public class BridgeJar( /** The sideload directory to use for every target, or null for the default of each target. */ private val overrideDir: Path?, - /** The home directory that holds the client's files. */ - private val home: Path, - /** The reader of the jar that this build embeds. */ - private val embedded: () -> ByteArray, ) { - /** Create an installer of the jar that ships with this build, for the clients of the current user. */ - public constructor(overrideDir: Path?) : this( - overrideDir, - Path.of(System.getProperty("user.home")), - { - val resource = - BridgeJar::class.java.getResourceAsStream("/$FILE_NAME") - ?: error("The bridge plugin jar is missing from this build") - - resource.use { it.readAllBytes() } - }, - ) - /** * Install the jar unless the installed one already has the same content. A client that has the * old jar open keeps reading it, because the new one is moved into place rather than written over it. */ public fun installFor(target: ProxyTargetConfig): Path { - // Custom targets run a client that keeps its files apart from those of the official one. - val dir = overrideDir ?: home.resolve(if (target.id == 0) ".runelite" else ".rlcustom").resolve(SIDELOAD_DIR) + val dir = overrideDir ?: defaultDir(target) val installed = dir.resolve(FILE_NAME) val wanted = embedded() @@ -49,6 +31,23 @@ public class BridgeJar internal constructor( return installed } + /** Get the sideload directory of the client of the target, in the home directory of the current user. */ + private fun defaultDir(target: ProxyTargetConfig): Path { + // Custom targets run a client that keeps its files apart from those of the official one. + val client = if (target.id == 0) ".runelite" else ".rlcustom" + + return Path.of(System.getProperty("user.home"), client, SIDELOAD_DIR) + } + + /** Read the jar that this build embeds. */ + private fun embedded(): ByteArray { + val resource = + BridgeJar::class.java.getResourceAsStream("/$FILE_NAME") + ?: error("The bridge plugin jar is missing from this build") + + return resource.use { it.readAllBytes() } + } + private companion object { /** The name of the jar, both as a resource and as installed. */ private const val FILE_NAME = "rsprox-mcp-bridge.jar" diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt index 585f184e..655bf849 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt @@ -26,6 +26,7 @@ public enum class Origin( PROXY('P'), } +/** One record of the log: a decoded packet, or a lifecycle marker that rsprox added. */ public data class PacketRecord( /** The position in the log: 1-based, contiguous, never reused. */ val seq: Long, @@ -37,12 +38,11 @@ public data class PacketRecord( val origin: Origin, /** The upper-case prot name such as `IF_SETTEXT`, or a lifecycle marker such as `LOGIN`. */ val prot: String, - /** The wall clock at append. Wire packets are flushed once per server tick, so this is tick-granular. */ - val atMs: Long, /** The decoded packet as text, or the detail of a lifecycle marker. */ val text: String, ) +/** What a read of the log asks for: where to start, which records to keep and how many. */ public data class PacketQuery( /** The cursor to read after. */ val after: Cursor = Cursor(0), @@ -54,7 +54,7 @@ public data class PacketQuery( val contains: String? = null, /** The most records to return. */ val limit: Int = DEFAULT_LIMIT, - /** The upper-case prefixes of which a prot name must start with one. Empty matches every prot. */ + /** The upper-case prefixes to keep. A prot name must start with one of them. Empty matches every prot. */ val protPrefixes: Set = emptySet(), ) { public companion object { @@ -63,6 +63,7 @@ public data class PacketQuery( } } +/** What a read of the log returns: the records that matched and where the read ended. */ public data class PacketPage( /** The records that matched, oldest first. */ val packets: List, @@ -106,7 +107,7 @@ public class PacketLog( ): Long = lock.withLock { val seq = firstSeq + records.size - records.addLast(PacketRecord(seq, login, cycle, origin, prot, System.currentTimeMillis(), text)) + records.addLast(PacketRecord(seq, login, cycle, origin, prot, text)) if (records.size > capacity) { records.removeFirst() @@ -127,44 +128,64 @@ public class PacketLog( waitMs: Long = 0, ): PacketPage { val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) - val matches = ArrayList() + var target = head().seq // A cursor past the head can only come from another log; reading from the head keeps it usable. - // The records are matched outside the lock, a slice at a time, so a broad query never holds up an append. - var slice = lock.withLock { sliceAfter(query.after.seq.coerceIn(0, headSeq()), headSeq()) } - var scanned = slice.first - 1 - var dropped = slice.dropped - var timedOut = false + val scan = Scan(query, query.after.seq.coerceIn(0, target)) while (true) { - for (record in slice.records) { - if (matches.size >= query.limit) break + scan.advance(target) - scanned++ - if (query.matches(record)) matches += record - } + if (!scan.isAt(target)) continue - val full = matches.size >= query.limit - var target = slice.target + if (scan.matches.isNotEmpty() || waitMs <= 0) return scan.page() - if (full || scanned >= target) { - if (matches.isNotEmpty() || waitMs <= 0) break + target = awaitAppend(target, deadline) ?: return scan.timedOutPage() + } + } - val head = awaitAppend(target, deadline) - if (head == null) { - timedOut = true - break - } + /** One read in progress: the records matched so far and how far the log has been scanned. */ + private inner class Scan( + /** The query that the read answers. */ + private val query: PacketQuery, + /** The sequence number of the last record that was scanned or evicted. */ + private var scanned: Long, + ) { + /** The records that matched so far, oldest first. */ + val matches = ArrayList() - target = head - } + /** The number of records that were evicted before they could be scanned. */ + private var dropped = 0L - slice = lock.withLock { sliceAfter(scanned, target) } + /** + * Scan the next slice of the records up to [target]. The records are matched outside the lock, + * a slice at a time, so a broad query never holds up an append. + */ + fun advance(target: Long) { + val slice = lock.withLock { sliceAfter(scanned, target) } scanned = slice.first - 1 dropped += slice.dropped + + for (record in slice.records) { + if (isFull()) return + + scanned++ + + if (query.matches(record)) matches += record + } } - return PacketPage(matches, Cursor(scanned), head(), dropped, timedOut) + /** Determine if the scan has nothing left to do up to [target]: it reached it or holds the limit. */ + fun isAt(target: Long): Boolean = isFull() || scanned >= target + + /** Build the page of a read that ended without a wait elapsing. */ + fun page(): PacketPage = PacketPage(matches, Cursor(scanned), head(), dropped, timedOut = false) + + /** Build the page of a read whose wait elapsed with no match. */ + fun timedOutPage(): PacketPage = PacketPage(matches, Cursor(scanned), head(), dropped, timedOut = true) + + /** Determine if the scan holds as many matches as the query asks for. */ + private fun isFull(): Boolean = matches.size >= query.limit } /** Get the cursor of the newest record. */ @@ -185,7 +206,7 @@ public class PacketLog( val count = (target - first + 1).coerceIn(0, SLICE) val offset = (first - firstSeq).toInt() - return Slice(first, first - (scanned + 1), target, List(count.toInt()) { records[offset + it] }) + return Slice(first, first - (scanned + 1), List(count.toInt()) { records[offset + it] }) } /** Wait for a record after [head] and return the new head, or null when the deadline passes first. */ @@ -217,13 +238,12 @@ public class PacketLog( return true } + /** A run of consecutive records, copied out of the log under its lock. */ private class Slice( /** The sequence number of the first record of the slice, or of the next record when the slice is empty. */ val first: Long, /** The number of records before [first] that were evicted before they could be scanned. */ val dropped: Long, - /** The sequence number that the scan runs up to before it looks at the head again. */ - val target: Long, /** The records of the slice, oldest first. */ val records: List, ) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt index bbfc5519..53f28677 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -75,7 +75,8 @@ internal class PacketTap( } } -internal class LoginTap( +/** The session monitor of one login, which appends the packets of that login to the log as text. */ +private class LoginTap( /** The log that the packets of this login go to. */ private val log: PacketLog, /** The registry that hears what happens to this login. */ @@ -110,7 +111,7 @@ internal class LoginTap( cache = cacheProvider } - /** Note the direction of the next packet, and on the first one that a transcriber is hooked for this login. */ + /** Keep the direction of the next packet. The first packet also registers that this login is being transcribed. */ override fun onPacketDirection(direction: StreamDirection) { this.direction = direction diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt index 0e953fc5..3ac4e4e1 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt @@ -37,6 +37,7 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { // } + /** The one filter set of the store, in which every filter is on, so that no packet is omitted. */ private object AllEnabled : PropertyFilterSet { /** Get a creation time of zero, since the set was never saved. */ override fun getCreationTime(): Long = 0 @@ -166,6 +167,7 @@ internal object TapSettingSetStore : SettingSetStore { // } + /** The one setting set of the store, whose values cannot be changed. */ private object Fixed : SettingSet { /** Get a creation time of zero, since the set was never saved. */ override fun getCreationTime(): Long = 0 diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/InteractionProof.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/InteractionProof.kt new file mode 100644 index 00000000..dc9a38a2 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/InteractionProof.kt @@ -0,0 +1,130 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import net.rsprox.mcp.packets.Cursor +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.PacketLog +import net.rsprox.mcp.packets.PacketQuery +import net.rsprox.mcp.packets.PacketRecord +import net.rsprox.mcp.session.Session +import java.util.concurrent.TimeUnit + +/** + * The prot prefixes that prove an interaction, for each expected prefix that is not its own only proof. + * A widget op on a dialog option runs the widget's script, which sends RESUME_PAUSEBUTTON and no button + * packet, and a close button sends CLOSE_MODAL. + */ +private val PROVING_PREFIXES = + mapOf("IF_BUTTON" to setOf("IF_BUTTON", "IF_SUBOP", "RESUME_PAUSEBUTTON", "CLOSE_MODAL")) + +/** The prot prefix of the record that the client sends for every press of a mouse button. */ +private const val MOUSE_CLICK = "EVENT_MOUSE_CLICK" + +/** The canvas position in the text of a mouse click record. */ +private val CLICK_POINT = Regex("""\bx=(\d+), y=(\d+)\b""") + +/** The note on an interaction whose login has no decoder, so that none of its packets reach the log. */ +private const val NOT_DECODED = + "the packets of this login are not decoded, so the action could not be confirmed in the packet log" + +/** The note on a widget interaction that the client performed without a packet. */ +private const val NO_PACKET = + "the client performed the option and sent no packet for it, which is normal for an option the client " + + "handles by itself" + +/** + * Add the packet that the client sent for an interaction to the plugin's [answer], as `packet`. + * The plugin names the prot prefix to expect and the canvas point it pressed, and neither is part of + * the result. Packets reach the log up to a tick late, so the packet is tied to this interaction by + * the client's own record of the press: it is the first packet with a proving prefix after that + * record, and `proof` is then `click`. When no such record arrives within [waitMs], the packet is the + * first one with a proving prefix after the answer's `cursor`, and `proof` is `prefix`. + * + * Throws [ToolError] when the client sent no packet for a target in the game world. A widget option + * that the client handles by itself sends none, so `packet` is then null and `note` says why. + */ +internal fun confirmSent( + session: Session, + answer: ObjectNode, + waitMs: Long, +): ObjectNode { + val expect = answer.remove("expect").asText() + val pressed = point(answer.remove("pressed")) + + if (session.logins.current()?.transcribing != true) return answer.putNull("packet").put("note", NOT_DECODED) + + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(waitMs) + val cursor = Cursor(answer.get("cursor").asLong()) + val click = awaitClick(session.packets, cursor, pressed, deadline) + val query = + PacketQuery( + after = if (click == null) cursor else Cursor(click.seq), + origin = Origin.CLIENT, + limit = 1, + protPrefixes = PROVING_PREFIXES[expect] ?: setOf(expect), + ) + + val sent = session.packets.read(query, remainingMs(deadline)).packets.firstOrNull() + if (sent == null) return unsent(answer, expect, waitMs) + + return answer.put("packet", line(sent)).put("proof", if (click == null) "prefix" else "click") +} + +/** + * Find the client's record of the press at [pressed] among the mouse clicks after [after]. + * Returns null when none arrives before [deadline]. + */ +private fun awaitClick( + packets: PacketLog, + after: Cursor, + pressed: Pair, + deadline: Long, +): PacketRecord? { + var cursor = after + + while (true) { + val query = PacketQuery(after = cursor, origin = Origin.CLIENT, protPrefixes = setOf(MOUSE_CLICK)) + val page = packets.read(query, remainingMs(deadline)) + val click = page.packets.firstOrNull { clickPoint(it.text) == pressed } + + if (click != null || page.packets.isEmpty()) return click + + cursor = page.next + } +} + +/** + * Get the canvas position in the text of a mouse click record, or null when the text holds none. + * The transcriber prints the record as + * `[event_mouse_click_v2] lasttransmitted=40ms, x=312, y=171, rightclick=false, code=0`. + */ +private fun clickPoint(text: String): Pair? { + val (x, y) = CLICK_POINT.find(text)?.destructured ?: return null + + return x.toInt() to y.toInt() +} + +/** Get the canvas point that the plugin answered as an array of its x and its y. */ +private fun point(pressed: JsonNode): Pair = pressed.get(0).asInt() to pressed.get(1).asInt() + +/** Get the milliseconds that are left until [deadline], or 0 once it has passed. */ +private fun remainingMs(deadline: Long): Long = + TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(0) + +/** + * Answer an interaction that the client sent no packet for. Throws [ToolError] for a target in the + * game world, where the client dropped the action. + */ +private fun unsent( + answer: ObjectNode, + expect: String, + waitMs: Long, +): ObjectNode { + val kind = TargetKind.of(answer.get("target").asText()) + if (!kind.inWorld) return answer.putNull("packet").put("note", NO_PACKET) + + val dropped = "the client sent no $expect packet within $waitMs ms, so the action was dropped" + + throw ToolError("$dropped. Likely cause: ${kind.dropCause}.") +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt index 22bbed64..d7a759dd 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -20,6 +20,7 @@ public class ToolError( message: String, ) : Exception(message) +/** What a tool returns, as the content blocks of an MCP reply. */ public sealed interface ToolResult { /** One text block holding [value] as compact JSON. */ public data class Json( @@ -42,6 +43,7 @@ public sealed interface ToolResult { ) : ToolResult } +/** One tool of the MCP server: its name, its description, the schema of its arguments and its handler. */ public class Tool( /** The name an MCP client calls the tool by. */ public val name: String, @@ -53,6 +55,7 @@ public class Tool( public val run: (args: ObjectNode) -> ToolResult, ) +/** The reply to one HTTP request. */ internal class HttpReply( /** The HTTP status code. */ val status: Int, @@ -60,6 +63,7 @@ internal class HttpReply( val body: String?, ) +/** A failure of the JSON-RPC message itself, which becomes a JSON-RPC error and not a tool result. */ private class RpcError( /** The JSON-RPC error code. */ val code: Int, @@ -232,7 +236,7 @@ internal class McpDispatcher( /** * Check the arguments against the schema of the tool. Throws [ToolError] for the first mismatch. * Checks the subset of JSON Schema the tool table uses: `required`, per-property `type`, `enum`, - * `minimum`, `maximum`, and `items.type` for arrays. Unknown keys are rejected so a misspelt + * `minimum`, `maximum`, and `items.type` and `items.enum` for arrays. Unknown keys are rejected so a misspelt * argument fails loudly instead of being ignored. */ private fun checkArguments( @@ -264,6 +268,12 @@ internal class McpDispatcher( if (mistyped) throw ToolError("${tool.name}: every item of '$key' must be of type $itemType") + val allowedItems = schema.get("items")?.get("enum") + if (allowedItems != null && value.any { it !in allowedItems }) { + val values = allowedItems.joinToString(", ") { it.asText() } + throw ToolError("${tool.name}: every item of '$key' must be one of $values") + } + val allowed = schema.get("enum") if (allowed != null && allowed.none { it == value }) { val values = allowed.joinToString(", ") { it.asText() } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt deleted file mode 100644 index 56a985c1..00000000 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/SentPacket.kt +++ /dev/null @@ -1,64 +0,0 @@ -package net.rsprox.mcp.server - -import com.fasterxml.jackson.databind.node.ObjectNode -import net.rsprox.mcp.packets.Cursor -import net.rsprox.mcp.packets.Origin -import net.rsprox.mcp.packets.PacketLog -import net.rsprox.mcp.packets.PacketQuery - -/** - * The prot prefixes that prove an interaction, for each expected prefix that is not its own only proof. - * A widget op on a dialog option runs the widget's script, which sends RESUME_PAUSEBUTTON and no button packet. - */ -private val PROVING_PREFIXES = mapOf("IF_BUTTON" to setOf("IF_BUTTON", "IF_SUBOP", "RESUME_PAUSEBUTTON")) - -/** The likely cause of a dropped interaction, by the kind of target the client drops silently. */ -private val DROP_CAUSES = - mapOf( - "npc" to "the NPC left the client's view", - "player" to "the player left the client's view", - "widget" to "the server has not enabled that op on the widget", - ) - -/** The likely cause of a dropped interaction with a target that the client sends a packet for unchecked. */ -private const val DROP_CAUSE = "the client logged out or lost its connection" - -/** - * Add the packet that the client sent for an interaction to the plugin's [answer], as `packet`. - * The packet is the first one from the client after the answer's `cursor` whose prot starts with the - * answer's `expect`. Throws [ToolError] when none arrives within [waitMs], or when a walk went to - * another tile than the one asked for. An answer without `expect` names nothing to wait for. - */ -internal fun confirmSent( - packets: PacketLog, - answer: ObjectNode, - waitMs: Long, -): ObjectNode { - val expect = answer.get("expect")?.asText() ?: return answer - val query = - PacketQuery( - after = Cursor(answer.get("cursor").asLong()), - origin = Origin.CLIENT, - limit = 1, - protPrefixes = PROVING_PREFIXES[expect] ?: setOf(expect), - ) - - val sent = packets.read(query, waitMs).packets.firstOrNull() ?: throw ToolError(dropped(answer, expect, waitMs)) - val tile = "coord=(${answer.get("x")}, ${answer.get("y")}, " - val elsewhere = expect == "MOVE_GAMECLICK" && tile !in sent.text - - if (elsewhere) throw ToolError("the client walked to another tile than the one asked for: ${line(sent)}") - - return answer.put("packet", line(sent)) -} - -/** Build the message for an interaction that the client sent no packet for. */ -private fun dropped( - answer: ObjectNode, - expect: String, - waitMs: Long, -): String { - val cause = DROP_CAUSES[answer.get("target")?.asText()] ?: DROP_CAUSE - - return "the client sent no $expect packet within $waitMs ms, so the action was dropped. Likely cause: $cause." -} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/TargetKind.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/TargetKind.kt new file mode 100644 index 00000000..b6284756 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/TargetKind.kt @@ -0,0 +1,54 @@ +package net.rsprox.mcp.server + +/** The likely cause when the client performs an action in the game world and sends no packet for it. */ +private const val LOST_CONNECTION = "the client logged out or lost its connection" + +/** + * The kinds of thing that client_interact acts on. The tool schemas and the proof of an interaction + * are derived from this one list. The plugin finds a target by the same name, in `Targets.java`. + */ +internal enum class TargetKind( + /** The name of the kind in the arguments and the result of a tool. */ + val wire: String, + /** The likely cause when the client performs an action on a target of the kind and sends no packet for it. */ + val dropCause: String = LOST_CONNECTION, +) { + /** An NPC, which the client drops an action on once the NPC is out of its view. */ + NPC("npc", "the NPC left the client's view"), + + /** An object on a tile. */ + OBJECT("object"), + + /** An item that lies on a tile. */ + GROUND_ITEM("ground_item"), + + /** Another player, which the client drops an action on once the player is out of its view. */ + PLAYER("player", "the player left the client's view"), + + /** A tile to walk to. */ + TILE("tile"), + + /** An interface component, some of whose options the client handles without a packet. */ + WIDGET("widget"), + + /** The open dialog, which the plugin resolves to the widget of the option. */ + DIALOG("dialog"), + ; + + /** Whether a target of the kind is in the game world, where the camera can look at it. */ + val inWorld: Boolean + get() = this != WIDGET && this != DIALOG + + /** Whether client_entities lists the targets of the kind. */ + val listed: Boolean + get() = inWorld && this != TILE + + companion object { + /** Get the names of the kinds that pass the test, in the order of the list. */ + fun names(wanted: (TargetKind) -> Boolean = { true }): Array = + entries.filter(wanted).map { it.wire }.toTypedArray() + + /** Get the kind with the name that the plugin answered with. */ + fun of(wire: String): TargetKind = entries.first { it.wire == wire } + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 9dd1f717..f448003c 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -5,10 +5,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode import net.rsprox.mcp.bridge.BridgeError import net.rsprox.mcp.packets.Cursor import net.rsprox.mcp.packets.Origin -import net.rsprox.mcp.packets.PacketLog import net.rsprox.mcp.packets.PacketPage import net.rsprox.mcp.packets.PacketQuery import net.rsprox.mcp.packets.PacketRecord +import net.rsprox.mcp.session.Session import net.rsprox.mcp.session.SessionManager /** @@ -119,16 +119,17 @@ public fun tools(sessions: () -> SessionManager): List = clientTool( name = "client_widgets", description = - "List the interface widgets that have text, a name or actions. Each has `id` " + + "List the interface widgets that have text, a name or options. Each has `id` " + "(\":\", or \":[]\" for a dynamic child), `text`, `name`, " + - "`actions`, `bounds` as [x, y, width, height], `click` as the [x, y] at its centre, `type` " + + "`actions` (its options), `bounds` as [x, y, width, height], `click` as the [x, y] at its " + + "centre, `type` " + "and `hidden`. `roots` lists the groups of the top-level interfaces. `truncated` is true " + "when `limit` cut the list short; narrow it with `group` or `text`.", schema = schema( "session" to SESSION, "group" to integer("Keep only widgets of this interface group, such as 558.", 0), - "text" to string("Case-insensitive substring to find in the text, name or actions."), + "text" to string("Case-insensitive substring to find in the text, name or options."), "hidden" to boolean("Also list hidden widgets. Default false."), "limit" to integer("Maximum number of widgets to return. Default $WIDGET_LIMIT.", 1, 2000), ), @@ -219,7 +220,7 @@ public fun tools(sessions: () -> SessionManager): List = schema( "session" to SESSION, "kinds" to - stringArray("Kinds to list. Default: all.", "npc", "object", "ground_item", "player"), + stringArray("Kinds to list. Default: all.", *TargetKind.names { it.listed }), "radius" to integer("Greatest distance from the local player in tiles. Default $ENTITY_RADIUS.", 0, 52), "name" to string("Case-insensitive substring the name must contain."), @@ -231,29 +232,36 @@ public fun tools(sessions: () -> SessionManager): List = clientTool( name = "client_interact", description = - "Perform one option on one thing in the game as a player does, and wait for the packet " + - "that proves the client sent it. The plugin moves the real mouse onto the target, lets " + - "the client build its menu, and clicks, through the right-click menu when the option is " + - "not the default; it retries within the game tick when the target moved, cancels a " + - "click that would hit anything else before the client sends it, and turns the camera " + - "when the target is out of view. The server receives exactly what a player's click sends, " + - "mouse packets included. Pick the target from client_entities: `npc` or `player` by " + - "`index`; `object` or `ground_item` by `id` and its tile `x`,`y`; `widget` by its " + - "client_widgets id; `dialog` with `option` \"continue\", or the text or 1-based number " + - "of a dialog choice; `tile` by `x`,`y`, which walks there and takes no `option`. " + - "`option` is one of the target's own options, in any case; an NPC, an object and a ground " + - "item also accept \"Examine\", and a widget \"Continue\". Returns what was resolved, " + - "`attempts`, `tick` (the client's tick count when it performed the action), `tried` " + - "(the last attempts in words), `camera` when it was turned, and `packet`, the line of " + - "the packet the client sent as packets_read prints it. Fails with `not_found` when the " + - "client does not have the target or the target does not offer the option, with " + - "`wrong_state` when the client never offered the option within the deadline (about " + - "three ticks, or six seconds after a camera turn), saying what it offered instead, and " + - "with an error that names the likely cause when the client sent no packet.", + "Perform one option on one thing in the game as a player does, and confirm it in the " + + "packet log. The plugin moves the real mouse onto the target and lets the client build " + + "its menu. It left-clicks when the option looks like the default one. Otherwise, or when " + + "the client shows that a left click performs another option there, it opens the " + + "right-click menu and clicks the row. It cancels a click that the client resolves to " + + "anything else before the client sends it, and aims again. It turns the camera when the " + + "target is out of view. The server receives exactly what a player's click sends, mouse " + + "packets included. " + + "Pick the target from client_entities. Name an `npc` or a `player` by `index`. Name an " + + "`object` or a `ground_item` by `id` and its tile `x`,`y`. Name a `widget` by its " + + "client_widgets id. For `dialog`, `option` is \"continue\", or the text or the 1-based " + + "number of a choice. A `tile` is named by `x`,`y`; the call walks there and takes no " + + "`option`. Otherwise `option` is one of the target's own options, in any case. An NPC, " + + "an object and a ground item also accept \"Examine\", and a widget \"Continue\". " + + "Returns what was resolved, `attempts`, `tick` (the client's tick count when it " + + "performed the action), `tried` (the last attempts in words) and `camera` when it was " + + "turned. `packet` is the line of the packet the client sent, as packets_read prints " + + "it. `proof` is `click` when that packet follows the client's own record of the " + + "press, and `prefix` when no such record arrived and the packet was matched by its " + + "name alone. `packet` is null, with a `note`, for a widget option that the client " + + "handles without a packet, and while the packets of the login are not decoded. " + + "Fails with `not_found` when the client does not have the target or the target does " + + "not offer the option. Fails with `wrong_state` when the client never offered the " + + "option within the deadline, and says what it offered instead. The deadline is about " + + "three ticks, or six seconds after a camera turn. Fails with an error that names the " + + "likely cause when the client sent no packet for a target in the game world.", schema = schema( "session" to SESSION, - "target" to string("Kind of thing to act on.", *TARGET_KINDS, "widget", "dialog"), + "target" to string("Kind of thing to act on.", *TargetKind.names()), "option" to string("Option to perform, such as \"Talk-to\". Required unless `target` is tile."), "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), @@ -264,7 +272,7 @@ public fun tools(sessions: () -> SessionManager): List = ), sessions = sessions, timeoutMs = { INTERACTION_TIMEOUT_MS }, - result = { ok, packets -> ToolResult.Json(confirmSent(packets, ok, SENT_WAIT_MS)) }, + result = { ok, session -> ToolResult.Json(confirmSent(session, ok, SENT_WAIT_MS)) }, ), clientTool( name = "client_camera", @@ -281,7 +289,10 @@ public fun tools(sessions: () -> SessionManager): List = "yaw" to integer("Yaw to turn to.", 0, 16383), "pitch" to integer("Pitch to turn to.", 1024, 3064), "look_at" to - string("Kind of target to face, named by the fields of client_interact.", *TARGET_KINDS), + string( + "Kind of target to face, named by the fields of client_interact.", + *TargetKind.names { it.inWorld }, + ), "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), "x" to integer("World x of the tile of the object, ground item or tile.", 0), @@ -306,7 +317,7 @@ private fun clientTool( sessions: () -> SessionManager, defaults: Map = emptyMap(), timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, - result: (ok: ObjectNode, packets: PacketLog) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, + result: (ok: ObjectNode, session: Session) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, ): Tool = Tool(name, "$description $CURSOR_NOTE", schema) { args -> val session = sessions().resolve(args.text("session")) @@ -320,7 +331,7 @@ private fun clientTool( val ok = session.requireLink().call(name.removePrefix("client_"), forwarded, timeoutMs(forwarded)) if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") - result(ok.put("cursor", cursor), session.packets) + result(ok.put("cursor", cursor), session) } /** The longest wait for the plugin to answer a call, on top of any wait the call itself asks for. */ @@ -332,9 +343,6 @@ private const val CLIENT_CALL_TIMEOUT_MS = 10_000L */ private const val INTERACTION_TIMEOUT_MS = 15_000L -/** The kinds of target that are in the game world, which both client_interact and client_camera accept. */ -private val TARGET_KINDS = arrayOf("npc", "object", "ground_item", "player", "tile") - /** The wait for a login to complete, unless the caller says otherwise. */ private const val LOGIN_WAIT_MS = 15_000L @@ -348,8 +356,8 @@ private const val ENTITY_RADIUS = 15L private const val ENTITY_LIMIT = 100L /** - * The wait for the packet of an interaction. Packets reach the log in batches at the end of each server - * tick, so the wait spans several ticks. + * The wait for the mouse click and the packet of an interaction. Packets reach the log in batches at + * the end of each server tick, so the wait spans several ticks. */ private const val SENT_WAIT_MS = 3_000L diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index 85c6f93d..b91cdb2a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -93,29 +93,28 @@ private const val BIND_ATTEMPTS = 64 /** * Get the first port from [allocate] that is free and that [bind] accepts, with what [bind] made for it. - * [bind] returns null for a port whose companion port is taken. Throws when no port in [attempts] works. + * [bind] returns null for a port whose companion port is taken. Throws when none of [BIND_ATTEMPTS] ports works. */ -internal fun firstBound( +private fun firstBound( allocate: () -> Int, isFree: (Int) -> Boolean, - attempts: Int = BIND_ATTEMPTS, bind: (Int) -> T?, ): Pair { - repeat(attempts) { + repeat(BIND_ATTEMPTS) { val port = allocate() val bound = if (isFree(port)) bind(port) else null if (bound != null) return port to bound } - throw IllegalStateException("none of $attempts proxy ports in a row could be bound with its HTTP port") + throw IllegalStateException("none of $BIND_ATTEMPTS proxy ports in a row could be bound with its HTTP port") } /** * The processes this JVM forks from the moment the watch is made. Launches take turns, so the forks that * follow a reservation are those of its launch. */ -internal class ForkWatch { +private class ForkWatch { /** The children that were running before the launch. */ private val before = children() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index cd6a3b95..509bbf97 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -28,21 +28,23 @@ public data class Launch( val proxyPort: Int, /** The identity the in-client bridge reports when it connects. */ val httpPort: Int, - /** The wall clock at which the launch began. */ - val startedAtMs: Long, ) +/** Whether a session has a client, and how far that client has come. */ public sealed interface ClientState { + /** The session has no client. */ public data class Stopped( /** The reason the session has no client. */ val reason: String, ) : ClientState + /** The client was launched and its plugin has not said hello yet. */ public data class Launching( /** The launch whose client has not connected yet. */ val launch: Launch, ) : ClientState + /** The plugin in the client is connected, so the client can be driven. */ public data class Connected( /** The launch that the client belongs to. */ val launch: Launch, @@ -55,11 +57,13 @@ public sealed interface ClientState { /** Everything that can change a session's [ClientState]. Several threads raise these; [reduce] decides. */ public sealed interface SessionEvent { + /** A launch of the client began. */ public data class Launched( /** The launch that began. */ val launch: Launch, ) : SessionEvent + /** The plugin of a launched client said hello. */ public data class Hello( /** The HTTP port of the launch that the client says it belongs to. */ val httpPort: Int, @@ -69,16 +73,19 @@ public sealed interface SessionEvent { val pid: Long, ) : SessionEvent + /** The link to the plugin of the connected client closed. */ public data class LinkClosed( /** The link that closed. */ val link: BridgeLink, ) : SessionEvent + /** The caller, or a failed launch, stops the client. */ public data class Stop( /** The reason the client is stopped. */ val reason: String, ) : SessionEvent + /** The plugin of a launched client will not say hello any more. */ public data class NeverConnected( /** The launch whose client will not connect any more. */ val launch: Launch, @@ -88,7 +95,7 @@ public sealed interface SessionEvent { } /** Get the state that follows [state]. Returns [state] itself when the event is stale or does not apply. */ -internal fun reduce( +private fun reduce( state: ClientState, event: SessionEvent, ): ClientState = @@ -117,6 +124,7 @@ internal fun reduce( } } +/** One login of a session, as the proxy reports it. */ public data class LoginInfo( /** The number of this login within its session, starting at 1. */ val epoch: Int, @@ -205,6 +213,7 @@ public data class SessionSnapshot( val cursor: Long, ) +/** One client of a proxy target, with its packet log and its logins, across every launch of that client. */ public class Session internal constructor( /** The id that callers name the session by. */ public val id: SessionId, @@ -253,7 +262,7 @@ public class Session internal constructor( } /** Apply the event and determine if it changed the state. */ - internal fun changes(event: SessionEvent): Boolean = + internal fun applied(event: SessionEvent): Boolean = lock.withLock { val before = client diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index 4a9fea8d..b83be666 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -26,6 +26,7 @@ internal interface ClientLauncher { fun kill(proxyPort: Int) } +/** The ports that were reserved for one launch, with the call that performs the launch. */ internal class Reservation( /** The port the client reaches the proxy on. */ val proxyPort: Int, @@ -40,6 +41,7 @@ internal class Reservation( val launcherExited: () -> Boolean, ) +/** Creates the sessions, launches and stops their clients, and feeds what happens to a client into its session. */ public class SessionManager internal constructor( /** The part of the proxy that launches and kills clients. */ private val launcher: ClientLauncher, @@ -171,15 +173,8 @@ public class SessionManager internal constructor( private fun launch(session: Session) { if (hungLaunch) throw ToolError(HUNG_LAUNCH) - val reservation = - try { - launcher.reserve(session.target) - } catch (e: Exception) { - throw ToolError("could not prepare target '${session.target.name}': ${rootMessage(e)}") - } - - val launch = - Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort, System.currentTimeMillis()) + val reservation = reserve(session) + val launch = Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort) sessions.addIfAbsent(session) @@ -187,6 +182,30 @@ public class SessionManager internal constructor( bridge.expect(launch.httpPort, listener(session, launch)) session.apply(SessionEvent.Launched(launch)) + val reason = fork(session, reservation) ?: return expectHello(session, launch) + + release(launch) + session.apply(SessionEvent.Stop(reason)) + throw ToolError("session ${session.id} failed to launch: $reason") + } + + /** Reserve the ports for a launch of the session. Throws [ToolError] when its target cannot be prepared. */ + private fun reserve(session: Session): Reservation = + try { + launcher.reserve(session.target) + } catch (e: Exception) { + throw ToolError("could not prepare target '${session.target.name}': ${rootMessage(e)}") + } + + /** + * Fork the client on a thread of its own and wait until its launcher has completed the handshake. + * Returns the reason when the launch failed or hung, and null when it succeeded. + * Must be called with [launchLock] held. + */ + private fun fork( + session: Session, + reservation: Reservation, + ): String? { // The proxy waits for the launcher's handshake with no timeout, so the wait is bounded from outside. val failure = AtomicReference() val thread = @@ -204,18 +223,11 @@ public class SessionManager internal constructor( hungLaunch = thread.isAlive - val reason = - when { - !hungLaunch -> failure.get()?.let(::rootMessage) - exited -> "the launcher exited before completing its handshake" - else -> "launcher never completed its handshake" - } - - if (reason == null) return expectHello(session, launch) - - release(launch) - session.apply(SessionEvent.Stop(reason)) - throw ToolError("session ${session.id} failed to launch: $reason") + return when { + !hungLaunch -> failure.get()?.let(::rootMessage) + exited -> "the launcher exited before completing its handshake" + else -> "launcher never completed its handshake" + } } /** @@ -261,7 +273,7 @@ public class SessionManager internal constructor( launch: Launch, reason: String, ) { - if (!session.changes(SessionEvent.NeverConnected(launch, reason))) return + if (!session.applied(SessionEvent.NeverConnected(launch, reason))) return synchronized(launchLock) { release(launch) } } @@ -293,8 +305,7 @@ public class SessionManager internal constructor( /** Stop the session when the link of its connected client closes, and release the proxy state. */ override fun onClosed(link: BridgeLink) { - val before = session.client - if (session.apply(SessionEvent.LinkClosed(link)) === before) return + if (!session.applied(SessionEvent.LinkClosed(link))) return // The client is gone, or cannot be driven any more. Either way the proxy still holds // its process handle and session monitor for the port. diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index 989f1ef7..5f2874ee 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -2,10 +2,6 @@ package net.rsprox.mcp.bridge import com.fasterxml.jackson.databind.node.ObjectNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import java.net.InetAddress -import java.net.Socket -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermissions import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit @@ -16,7 +12,6 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertSame -import kotlin.test.assertTrue class BridgeHubTest { private val mapper = jacksonObjectMapper() @@ -33,8 +28,7 @@ class BridgeHubTest { private fun dial( httpPort: Int, token: String? = null, - protocol: Int = 1, - ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, token, protocol).also { plugins += it } + ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, token).also { plugins += it } private fun args(json: String = "{}"): ObjectNode = mapper.readTree(json) as ObjectNode diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index 738df5cf..0c819ceb 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -2,11 +2,7 @@ package net.rsprox.mcp.bridge import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import java.io.BufferedReader -import java.io.Closeable import java.io.IOException -import java.io.StringReader -import java.io.StringWriter import java.net.InetAddress import java.net.Socket import java.nio.file.Files @@ -16,9 +12,6 @@ import kotlin.concurrent.thread private val mapper = jacksonObjectMapper() -/** A link that is connected to nothing, for tests that only need its identity. */ -internal fun idleLink(): BridgeLink = BridgeLink(Closeable {}, BufferedReader(StringReader("")), StringWriter()) {} - /** Polls until [condition] holds, so a test fails with a message instead of hanging. */ internal fun awaitTrue( what: String, @@ -33,11 +26,9 @@ internal fun awaitTrue( } /** A started hub whose rendezvous file lives in a temporary directory. */ -internal class TestHub( - rendering: Rendering = Rendering.GPU, -) : AutoCloseable { +internal class TestHub : AutoCloseable { val rendezvous: Path = Files.createTempDirectory("mcp-bridge-test").resolve("mcp").resolve("bridge.json") - val hub = BridgeHub(rendezvous, rendering).also { it.start() } + val hub = BridgeHub(rendezvous, Rendering.GPU).also { it.start() } override fun close() { hub.close() @@ -49,11 +40,9 @@ internal class TestHub( internal class RecordingListener( override val session: String = "s1", - private val accept: Boolean = true, ) : BridgeListener { val hello = CompletableFuture>() val closed = CompletableFuture() - val rejected = CompletableFuture() override fun onHello( link: BridgeLink, @@ -61,7 +50,7 @@ internal class RecordingListener( ): Boolean { hello.complete(link to pid) - return accept + return true } override fun onClosed(link: BridgeLink) { @@ -69,7 +58,7 @@ internal class RecordingListener( } override fun onRejected(reason: String) { - rejected.complete(reason) + // } } @@ -115,7 +104,6 @@ internal class FakePlugin private constructor( httpPort: Int, token: String? = null, protocol: Int = 1, - pid: Long = 4242, ): FakePlugin { val file = mapper.readTree(Files.readAllBytes(rendezvous)) val socket = Socket(InetAddress.getLoopbackAddress(), file.get("port").asInt()) @@ -127,7 +115,7 @@ internal class FakePlugin private constructor( .put("hello", protocol) .put("httpPort", httpPort) .put("token", token ?: file.get("token").asText()) - .put("pid", pid) + .put("pid", 4242) plugin.send(mapper.writeValueAsString(hello)) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt new file mode 100644 index 00000000..08f189ab --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt @@ -0,0 +1,364 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.bridge.FakePlugin +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.packets.Origin +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.SessionManager +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.shared.StreamDirection +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ToolCallsTest { + private val launcher = FakeLauncher() + private val fixture = TestHub() + private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub) + private val server = McpHttpServer(0, tools { manager }, "1.2.3").also { it.start() } + private val http = HttpClient.newHttpClient() + private val plugins = ArrayList() + private val talkTo = """{"target":"npc","index":0,"option":"Talk-to"}""" + + // Every request the plugin received, as its op and its arguments. + private val received = CopyOnWriteArrayList() + + // What the plugin answers a request with. It runs before the answer is sent, as the client's packets do. + @Volatile + private var answer: (args: JsonNode) -> String = { """"ok":{}""" } + + private val header = + BinaryHeader( + headerVersion = 1, + revision = 235, + subRevision = 1, + clientType = 1, + platformType = 1, + timestamp = 0, + worldId = 301, + worldFlags = 0, + worldLocation = 0, + worldHost = "127.0.0.1", + worldActivity = "", + localPlayerIndex = 1, + accountHash = ByteArray(0), + clientName = "RuneLite", + js5MasterIndex = ByteArray(0), + ) + + @AfterTest + fun cleanUp() { + server.close() + plugins.forEach { it.close() } + fixture.close() + } + + private fun rpc( + method: String, + params: String, + ): JsonNode { + val body = """{"jsonrpc":"2.0","id":1,"method":"$method","params":$params}""" + val request = + HttpRequest + .newBuilder(URI("http://127.0.0.1:${server.localPort}/mcp")) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + + val response = http.send(request, HttpResponse.BodyHandlers.ofString()) + + return McpDispatcher.MAPPER.readTree(response.body()).get("result") + } + + private fun toolResult( + tool: String, + arguments: String, + ): JsonNode = rpc("tools/call", """{"name":"$tool","arguments":$arguments}""") + + private fun call( + tool: String, + arguments: String = "{}", + ): String { + val result = toolResult(tool, arguments) + val text = result.get("content").last().get("text").asText() + assertFalse(result.get("isError").asBoolean(), text) + + return text + } + + private fun callJson( + tool: String, + arguments: String = "{}", + ): JsonNode = McpDispatcher.MAPPER.readTree(call(tool, arguments)) + + private fun error( + tool: String, + arguments: String, + ): String { + val result = toolResult(tool, arguments) + val text = result.get("content").single().get("text").asText() + assertTrue(result.get("isError").asBoolean(), text) + + return text + } + + private fun connect() { + call("session_start", """{"target":"My Server","wait_ms":0}""") + val plugin = FakePlugin.dial(fixture.rendezvous, FakeLauncher.FIRST_HTTP_PORT).also { plugins += it } + plugin.read() + plugin.serve { op, args -> + received += "$op $args" + answer(args) + } + + val started = callJson("session_start", """{"session":"s1","wait_ms":10000}""") + assertEquals("connected", started.get("state").asText()) + } + + private fun logIn(decoded: Boolean = true) { + val tap = launcher.monitor.forSession(header) + tap.onLogin(header) + + if (decoded) tap.onPacketDirection(StreamDirection.CLIENT_TO_SERVER) + } + + private fun sent( + prot: String, + text: String, + origin: Origin = Origin.CLIENT, + tick: Int = 7, + ): Long = manager.resolve("s1").packets.append(1, tick, origin, prot, text) + + private fun clicked( + x: Int, + y: Int, + ): Long = + sent( + "EVENT_MOUSE_CLICK_V2", + "[event_mouse_click_v2] lasttransmitted=40ms, x=$x, y=$y, rightclick=false, code=0", + ) + + private fun performed( + target: String, + expect: String, + ): String = + """"ok":{"target":"$target","option":"Talk-to","attempts":1,"tick":41,""" + + """"expect":"$expect","pressed":[312,171],"tried":[]}""" + + private fun rows(arguments: String): List = call("packets_read", arguments).lines().drop(1) + + @Test + fun `the tool list names the fourteen tools, each with a description and an object schema`() { + val listed = rpc("tools/list", "{}").get("tools") + + assertEquals( + listOf( + "session_start", + "session_stop", + "session_list", + "packets_read", + "client_state", + "client_screenshot", + "client_widgets", + "client_vars", + "client_login", + "client_click", + "client_type", + "client_entities", + "client_interact", + "client_camera", + ), + listed.map { it.get("name").asText() }, + ) + + for (tool in listed) { + assertTrue(tool.get("description").asText().isNotBlank(), tool.toString()) + assertEquals("object", tool.get("inputSchema").get("type").asText(), tool.toString()) + } + } + + @Test + fun `a client tool calls the plugin op of its name and returns its answer with the cursor before the call`() { + connect() + answer = { + sent("EVENT_MOUSE_CLICK_V2", "[event_mouse_click_v2] x=10, y=20") + """"ok":{"x":10,"y":20}""" + } + + val result = call("client_click", """{"session":"s1","x":10,"y":20,"widget":null}""") + + assertEquals("""{"x":10,"y":20,"cursor":2}""", result) + assertEquals(listOf("""click {"x":10,"y":20}"""), received) + } + + @Test + fun `invalid arguments are refused by name before anything reaches the plugin`() { + connect() + + assertContains(error("client_click", """{"x":"ten","y":20}"""), "'x' must be of type integer") + assertContains(error("client_click", """{"x":10,"y":20,"z":1}"""), "unknown argument 'z'") + assertContains( + error("client_entities", """{"kinds":["npc","dragon"]}"""), + "every item of 'kinds' must be one of npc, object", + ) + + assertEquals(emptyList(), received) + } + + @Test + fun `an interaction returns the action packet that follows the mouse click of the pressed point`() { + connect() + logIn() + answer = { + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=9, id=3010)") + clicked(312, 171) + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=0, id=3308)") + performed("npc", "OPNPC") + } + + val result = callJson("client_interact", talkTo) + + assertEquals("6 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308)", result.get("packet").asText()) + assertEquals("click", result.get("proof").asText()) + assertEquals(3, result.get("cursor").asInt()) + assertFalse(result.has("expect"), result.toString()) + assertFalse(result.has("pressed"), result.toString()) + assertEquals(listOf("interact $talkTo"), received) + } + + @Test + fun `an interaction without a mouse click of the pressed point returns the first packet of the expected prefix`() { + connect() + logIn() + answer = { + clicked(40, 50) + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=0, id=3308)") + sent("OPNPC3_V2", "[opnpc3_v2] npc=(index=0, id=3308)") + performed("npc", "OPNPC") + } + + val result = callJson("client_interact", talkTo) + + assertEquals("5 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308)", result.get("packet").asText()) + assertEquals("prefix", result.get("proof").asText()) + } + + @Test + fun `an interaction the client sent no packet for fails as dropped in the world and is noted for a widget`() { + connect() + logIn() + answer = { args -> + if (args.get("target").asText() == "npc") performed("npc", "OPNPC") else performed("widget", "IF_BUTTON") + } + + // Each call waits three seconds for a packet, so the two wait side by side. + val npc = CompletableFuture.supplyAsync { error("client_interact", talkTo) } + val widget = callJson("client_interact", """{"target":"widget","widget":"558:7","option":"Toggle"}""") + + assertContains(npc.get(10, TimeUnit.SECONDS), "no OPNPC packet") + assertContains(npc.get(), "the action was dropped. Likely cause: the NPC left the client's view") + assertTrue(widget.get("packet").isNull, widget.toString()) + assertContains(widget.get("note").asText(), "sent no packet for it") + assertFalse(widget.has("proof"), widget.toString()) + } + + @Test + fun `a widget interaction is proven by the packet of a dialog option and by that of a close button`() { + connect() + logIn() + answer = { + clicked(312, 171) + sent("RESUME_PAUSEBUTTON", "[resume_pausebutton] com=219:1") + performed("widget", "IF_BUTTON") + } + + val option = callJson("client_interact", """{"target":"dialog","option":"2"}""") + + assertEquals("5 L1 T7 C RESUME_PAUSEBUTTON [resume_pausebutton] com=219:1", option.get("packet").asText()) + assertEquals("click", option.get("proof").asText()) + + answer = { + clicked(312, 171) + sent("CLOSE_MODAL", "[close_modal]") + performed("widget", "IF_BUTTON") + } + + val close = callJson("client_interact", """{"target":"widget","widget":"12:2","option":"Close"}""") + + assertEquals("7 L1 T7 C CLOSE_MODAL [close_modal]", close.get("packet").asText()) + assertEquals("click", close.get("proof").asText()) + } + + @Test + fun `an interaction on a login whose packets are not decoded returns unconfirmed without waiting`() { + connect() + logIn(decoded = false) + answer = { performed("npc", "OPNPC") } + + val result = callJson("client_interact", talkTo) + + assertTrue(result.get("packet").isNull, result.toString()) + assertContains(result.get("note").asText(), "not decoded, so the action could not be confirmed") + assertFalse(result.has("expect"), result.toString()) + } + + @Test + fun `packets are read as a meta line and one row each, filtered by cursor, prot, origin and text`() { + connect() + logIn() + sent("OPNPC1_V2", "[opnpc1_v2] npc=(index=0, id=3308)") + sent("IF_SETTEXT", "[if_settext] com=558:7, text=Hello there", Origin.SERVER, tick = 8) + sent("IF_BUTTONX", "[if_buttonx] com=558:7, op=1", tick = 8) + + assertEquals( + """ + {"next":6,"head":6,"dropped":0,"timedOut":false,"count":4} + 3 L1 T0 P LOGIN revision=235 world=301 + 4 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308) + 5 L1 T8 S IF_SETTEXT [if_settext] com=558:7, text=Hello there + 6 L1 T8 C IF_BUTTONX [if_buttonx] com=558:7, op=1 + """.trimIndent(), + call("packets_read", """{"after":2}"""), + ) + + assertEquals(listOf("5", "6"), rows("""{"after":4}""").map { it.substringBefore(' ') }) + assertEquals(listOf("5"), rows("""{"prots":["if_settext"]}""").map { it.substringBefore(' ') }) + assertEquals(listOf("4", "6"), rows("""{"origin":"client"}""").map { it.substringBefore(' ') }) + assertEquals(listOf("5"), rows("""{"contains":"hello THERE"}""").map { it.substringBefore(' ') }) + } + + @Test + fun `a packet read that waits returns once a matching packet is appended and reports a timeout when none is`() { + connect() + logIn() + val read = """{"after":3,"contains":"farewell","wait_ms":60000}""" + val waiting = CompletableFuture.supplyAsync { call("packets_read", read) } + + sent("IF_SETTEXT", "[if_settext] com=558:7, text=Hello there", Origin.SERVER) + sent("IF_SETTEXT", "[if_settext] com=558:7, text=Farewell", Origin.SERVER) + + assertEquals( + """ + {"next":5,"head":5,"dropped":0,"timedOut":false,"count":1} + 5 L1 T7 S IF_SETTEXT [if_settext] com=558:7, text=Farewell + """.trimIndent(), + waiting.get(10, TimeUnit.SECONDS), + ) + + assertEquals( + """{"next":5,"head":5,"dropped":0,"timedOut":true,"count":0}""", + call("packets_read", """{"after":5,"wait_ms":50}"""), + ) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt index 610f7245..c36ed7e3 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt @@ -1,6 +1,8 @@ package net.rsprox.mcp.session +import net.rsprox.proxy.binary.BinaryHeader import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor import java.util.concurrent.CopyOnWriteArrayList internal fun target( @@ -20,25 +22,33 @@ internal fun target( binaryFolder = null, ) -internal class FakeLauncher( - private val targets: List = listOf(target(0, "Old School RuneScape"), target(1, "My Server")), -) : ClientLauncher { +internal class FakeLauncher : ClientLauncher { + private val targets = listOf(target(0, "Old School RuneScape"), target(1, "My Server")) val reserved = ArrayList() // A client that goes away is killed from the bridge's reader thread. val killed = CopyOnWriteArrayList() - var reserve: (ProxyTargetConfig) -> Unit = {} var launch: () -> Unit = {} var launcherExited: () -> Boolean = { false } + // The packet tap of the newest launch, which a test drives the way the proxy does. + lateinit var monitor: SessionMonitor + override fun targets(): List = targets override fun reserve(target: ProxyTargetConfig): Reservation { - reserve.invoke(target) reserved += target val earlier = reserved.size - 1 - return Reservation(FIRST_PROXY_PORT + earlier, FIRST_HTTP_PORT + earlier, { launch() }, { launcherExited() }) + return Reservation( + FIRST_PROXY_PORT + earlier, + FIRST_HTTP_PORT + earlier, + { tap -> + monitor = tap + launch() + }, + { launcherExited() }, + ) } override fun kill(proxyPort: Int) { diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java index 0484ae0d..39ea42a4 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Aiming.java @@ -16,7 +16,7 @@ final class Aiming { {0.25, 0.25}, {0.75, 0.75}, {0.25, 0.75}, {0.75, 0.25}, }; - /** Not for instances. */ + /** Prevent the creation of instances. */ private Aiming() { // } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java index 406af9c2..4d9669ac 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java @@ -12,11 +12,11 @@ * client moves the camera toward its target over several ticks. */ final class Camera implements Ops.Op { - /** The number of frames over which the camera must not move to count as settled. */ - private static final int SETTLED_FRAMES = 3; + /** The cycles over which neither the yaw nor the pitch may change for the camera to count as settled. */ + private static final int SETTLED_CYCLES = 10; - /** The most frames a turn is given to settle. */ - private static final int TURN_FRAMES = 300; + /** The most cycles a turn is given to settle, which is six seconds. */ + private static final int TURN_CYCLES = 300; /** The gateway to the client and the threads it must be used on. */ private final GameAccess game; @@ -26,35 +26,28 @@ final class Camera implements Ops.Op { this.game = game; } + /** Determine if the arguments ask for a turn of the camera, and not only for a read of it. */ + static boolean turns(JsonObject args) { + return args.has("yaw") || args.has("pitch") || args.has("look_at"); + } + /** * Turn the camera as asked, or read it, and report its yaw and pitch, with whether the looked-at * target is on screen. */ @Override public JsonElement run(JsonObject args) throws BridgeException { - Integer yaw = Ops.optionalInt(args, "yaw"); - Integer pitch = Ops.optionalInt(args, "pitch"); String lookAt = Ops.optionalString(args, "look_at"); - requireRanges(yaw, pitch); Target target = lookAt == null ? null : locate(lookAt, args); - boolean turned = game.onClientThread(client -> turn(client, yaw, pitch, target)); - if (turned) settle(); + if (turns(args)) { + turn(args, target); + settle(); + } return game.onClientThread(client -> describe(client, target)); } - /** Check that the angles are within the client's ranges. Throws {@code bad_args} when one is not. */ - private static void requireRanges(Integer yaw, Integer pitch) throws BridgeException { - boolean badYaw = yaw != null && (yaw < 0 || yaw >= CameraTurn.FULL_TURN); - if (badYaw) throw new BridgeException("bad_args", "yaw must be from 0 to " + (CameraTurn.FULL_TURN - 1)); - - boolean badPitch = pitch != null && (pitch < CameraTurn.PITCH_MIN || pitch > CameraTurn.PITCH_MAX); - String range = "pitch must be from " + CameraTurn.PITCH_MIN + " to " + CameraTurn.PITCH_MAX; - - if (badPitch) throw new BridgeException("bad_args", range); - } - /** Find the target of the kind that look_at names, from the same fields as an interaction takes. */ private Target locate(String kind, JsonObject args) throws BridgeException { JsonObject named = args.deepCopy(); @@ -64,26 +57,20 @@ private Target locate(String kind, JsonObject args) throws BridgeException { return game.onClientThread(client -> Targets.locate(client, named)); } - /** Set the camera's targets from the arguments, and report whether any was set. */ - private static boolean turn(Client client, Integer yaw, Integer pitch, Target target) throws BridgeException { - boolean turned = false; + /** Set the camera's targets: the yaw that faces the target, then the yaw and the pitch that are given. */ + private void turn(JsonObject args, Target target) throws BridgeException { + Integer yaw = Ops.optionalInt(args, "yaw"); + Integer pitch = Ops.optionalInt(args, "pitch"); - if (target != null) { - client.setCameraYawTarget(yawToward(client, target)); - turned = true; - } + game.onClientThread(client -> { + if (target != null) client.setCameraYawTarget(yawToward(client, target)); - if (yaw != null) { - client.setCameraYawTarget(yaw); - turned = true; - } + if (yaw != null) client.setCameraYawTarget(yaw); - if (pitch != null) { - client.setCameraPitchTarget(pitch); - turned = true; - } + if (pitch != null) client.setCameraPitchTarget(pitch); - return turned; + return null; + }); } /** Get the yaw that faces the target from the local player. Throws {@code wrong_state} while there is no player. */ @@ -92,9 +79,7 @@ private static int yawToward(Client client, Target target) throws BridgeExceptio if (local == null) throw new BridgeException("wrong_state", "the client is not in the game"); WorldPoint tile = target.tile(client); - String refusal = target.label() + " is not in the world, so the camera cannot look at it"; - - if (tile == null) throw new BridgeException("not_found", refusal); + if (tile == null) throw new BridgeException("not_found", target.label() + " is not in the world"); LocalPoint there = LocalPoint.fromWorld(client.getTopLevelWorldView(), tile); if (there == null) throw new BridgeException("not_found", target.label() + " is outside the loaded scene"); @@ -102,16 +87,21 @@ private static int yawToward(Client client, Target target) throws BridgeExceptio return CameraTurn.yawToward(local.getLocalLocation(), there); } - /** Wait until the yaw and the pitch have stopped changing for a few frames, within the bound. */ + /** Wait until the yaw and the pitch have not changed for the settled number of cycles, within the bound. */ private void settle() throws BridgeException { - long last = -1; - int still = 0; + int start = game.cycle(); + int stillSince = start; + int now = start; + long last = game.onClientThread(Camera::angles); - for (int frame = 0; frame < TURN_FRAMES && still < SETTLED_FRAMES; frame++) { + while (now - stillSince < SETTLED_CYCLES && now - start < TURN_CYCLES) { game.nextFrame(); - long now = game.onClientThread(Camera::angles); - still = now == last ? still + 1 : 0; - last = now; + now = game.cycle(); + long angles = game.onClientThread(Camera::angles); + + if (angles != last) stillSince = now; + + last = angles; } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java index 0b412e1a..de07cceb 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/CameraTurn.java @@ -13,16 +13,10 @@ */ final class CameraTurn { /** The number of angle units in a full turn. */ - static final int FULL_TURN = 16384; - - /** The lowest pitch the client accepts. */ - static final int PITCH_MIN = 1024; - - /** The highest pitch the client accepts. */ - static final int PITCH_MAX = 3064; + private static final int FULL_TURN = 16384; /** The pitch that shows a good stretch of ground in front of the player. */ - static final int PITCH_MIDDLE = 2048; + private static final int PITCH_MIDDLE = 2048; /** The distance within which the yaw counts as having arrived at its target. */ private static final int ARRIVED_WITHIN = 64; diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java index 2fe9d0c6..3591e329 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java @@ -5,14 +5,26 @@ /** * Decides, on the click the client is about to perform, whether it is the intended one. It is armed - * just before a click of ours and disarmed on the first click the client reports, so that a click - * that is not ours is never judged. + * just before a click of ours and stays armed until the client reports a click or the interaction + * gives up on it, so that a click that is not ours is never judged. */ final class ClickGuard { /** What became of one attempt. */ + enum Outcome { + /** The client performed the intended action. */ + PERFORMED, + + /** The client performed nothing. */ + MISSED, + + /** The client was stopped from performing another entry at a point where it also offered the intended one. */ + ANOTHER_PREFERRED, + } + + /** The outcome of one attempt, with what happened in words. */ static final class Verdict { - /** Whether the client performed the intended action. */ - final boolean passed; + /** What became of the attempt. */ + final Outcome outcome; /** What happened, in a few words. */ final String words; @@ -21,20 +33,30 @@ static final class Verdict { final int tick; /** Create a verdict. */ - private Verdict(boolean passed, String words, int tick) { - this.passed = passed; + private Verdict(Outcome outcome, String words, int tick) { + this.outcome = outcome; this.words = words; this.tick = tick; } /** Build the verdict of an attempt that the client performed on the given tick. */ - static Verdict passed(String words, int tick) { - return new Verdict(true, words, tick); + private static Verdict performed(String words, int tick) { + return new Verdict(Outcome.PERFORMED, words, tick); } /** Build the verdict of an attempt that performed nothing. */ static Verdict missed(String words) { - return new Verdict(false, words, -1); + return new Verdict(Outcome.MISSED, words, -1); + } + + /** Determine if the client performed the intended action. */ + boolean isPerformed() { + return outcome == Outcome.PERFORMED; + } + + /** Get the same verdict with other words. */ + Verdict worded(String words) { + return new Verdict(outcome, words, tick); } } @@ -59,15 +81,18 @@ synchronized void arm() { } /** Determine if the client has reported the pending click. */ - synchronized boolean settled() { + synchronized boolean isSettled() { return verdict != null; } - /** Stop expecting a click, and get the verdict, which is the given words when the client reported none. */ - synchronized Verdict disarm(String whenNone) { + /** + * Stop expecting a click, and get the verdict, or null when the client reported none. Called on + * the client thread, where the client reports its clicks, so that no report can slip in between. + */ + synchronized Verdict disarm() { armed = false; - return verdict != null ? verdict : Verdict.missed(whenNone); + return verdict; } /** @@ -78,15 +103,35 @@ synchronized void on(Client client, MenuOptionClicked event) { if (!armed) return; armed = false; + verdict = judge(client, event); + } + + /** Let the intended click through and consume any other, and say which it was. */ + private Verdict judge(Client client, MenuOptionClicked event) { String words = Offer.words(event.getMenuEntry()); - if (event.isConsumed()) { - verdict = Verdict.missed("another plugin consumed the click on " + words); - } else if (target.matches(event.getMenuEntry(), client)) { - verdict = Verdict.passed("the client performed " + words, client.getTickCount()); - } else { - event.consume(); - verdict = Verdict.missed("the click would have performed " + words + ", so it was cancelled"); - } + if (event.isConsumed()) return Verdict.missed("another plugin consumed the click on " + words); + + if (target.matches(event.getMenuEntry(), client)) return performed(client, words); + + event.consume(); + + return cancelled(client, words); + } + + /** Build the verdict of the intended click, which the client performs on its current tick. */ + private static Verdict performed(Client client, String words) { + return Verdict.performed("the client performed " + words, client.getTickCount()); + } + + /** + * Build the verdict of a cancelled click. When the menu still holds the intended entry, the client + * preferred another entry to it at this point, which a click through the open menu gets around. + */ + private Verdict cancelled(Client client, String words) { + boolean offered = Offer.read(client, target, null).match >= 0; + Outcome outcome = offered ? Outcome.ANOTHER_PREFERRED : Outcome.MISSED; + + return new Verdict(outcome, "the click would have performed " + words + ", so it was cancelled", -1); } } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java index a9619bee..1b488a42 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java @@ -59,24 +59,19 @@ public JsonElement run(JsonObject args) throws BridgeException { }); } - /** Get the kinds to list. Throws {@code bad_args} for a kind that does not exist. */ - private static Set kinds(JsonObject args) throws BridgeException { - JsonElement asked = args.get("kinds"); - if (asked == null || asked.isJsonNull()) return new LinkedHashSet<>(KINDS); - + /** Get the kinds to list, which are all of them unless the arguments name some. */ + private static Set kinds(JsonObject args) { Set kinds = new LinkedHashSet<>(); - for (JsonElement kind : asked.getAsJsonArray()) { - if (!KINDS.contains(kind.getAsString())) throw new BridgeException("bad_args", "kinds must be of " + KINDS); - + for (JsonElement kind : args.has("kinds") ? args.getAsJsonArray("kinds") : new JsonArray()) { kinds.add(kind.getAsString()); } - return kinds; + return kinds.isEmpty() ? new LinkedHashSet<>(KINDS) : kinds; } /** One entity that passed the filters. */ - private static final class Found implements Comparable { + private static final class Entry implements Comparable { /** The kind of the entity. */ final String kind; @@ -96,7 +91,7 @@ private static final class Found implements Comparable { final JsonObject described; /** Create the record of one entity. */ - Found(String kind, int distance, int key, int x, int y, JsonObject described) { + Entry(String kind, int distance, int key, int x, int y, JsonObject described) { this.kind = kind; this.distance = distance; this.key = key; @@ -107,7 +102,7 @@ private static final class Found implements Comparable { /** Order the nearest first, then by index or id, then by tile, so a listing is stable. */ @Override - public int compareTo(Found other) { + public int compareTo(Entry other) { if (distance != other.distance) return Integer.compare(distance, other.distance); if (key != other.key) return Integer.compare(key, other.key); @@ -121,7 +116,7 @@ public int compareTo(Found other) { /** Collects the entities around the local player on its plane, in the top-level world view. */ private static final class Survey { /** The entities collected so far. */ - private final List found = new ArrayList<>(); + private final List found = new ArrayList<>(); /** The game client. */ private final Client client; @@ -254,7 +249,7 @@ private void add(String kind, int key, int x, int y, JsonObject described) { String name = described.get("name").getAsString().toLowerCase(Locale.ROOT); if (needle != null && !name.contains(needle)) return; - found.add(new Found(kind, distance, key, x, y, described)); + found.add(new Entry(kind, distance, key, x, y, described)); } /** Get the world x of the scene tile. */ @@ -281,7 +276,7 @@ JsonObject result(int limit) { out.add(kind + "s", new JsonArray()); } - for (Found entity : found.subList(0, Math.min(limit, found.size()))) { + for (Entry entity : found.subList(0, Math.min(limit, found.size()))) { out.getAsJsonArray(entity.kind + "s").add(entity.described); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java index 037b5dca..1a2f13bf 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java @@ -44,7 +44,7 @@ final class GameAccess implements AutoCloseable { /** The subscription that keeps {@link #tickCycle} current, for as long as this access is open. */ private final Subscription ticks; - /** The game cycle at which the last game tick started, or the lowest value before the first. */ + /** The client cycle at which the last game tick started, or the lowest value before the first. */ private volatile int tickCycle = Integer.MIN_VALUE; /** Create the access to the given client, its thread, its renderer and its event bus. */ @@ -56,16 +56,19 @@ final class GameAccess implements AutoCloseable { this.ticks = subscribe(GameTick.class, (game, tick) -> tickCycle = game.getGameCycle()); } + /** A read of the client, or an act on it, that runs on the client thread. */ interface ClientCall { /** Read from or act on the client, on the client thread. */ T call(Client client) throws BridgeException; } + /** A handler of one type of event that the client posts. */ interface ClientEvent { /** Handle an event that the client posted, on the thread it posted it from. */ void on(Client client, T event); } + /** A registration for events, which lasts until it is closed. */ interface Subscription extends AutoCloseable { /** Stop receiving the events. Idempotent. */ @Override @@ -105,7 +108,7 @@ Subscription subscribe(Class type, ClientEvent handler) { return () -> eventBus.unregister(subscriber); } - /** Get the game cycle at which the last game tick started, or the lowest value before the first. */ + /** Get the client cycle at which the last game tick started, or the lowest value before the first. */ int tickCycle() { return tickCycle; } @@ -125,12 +128,43 @@ BufferedImage frame() throws BridgeException { return await(result, "a rendered frame"); } - /** Wait until the client has drawn its next frame. The image is not kept. */ + /** + * Wait until the client has drawn its next frame. The listener takes no image, so the GPU plugin + * does not read the framebuffer back for it. + */ void nextFrame() throws BridgeException { CompletableFuture drawn = new CompletableFuture<>(); - drawManager.requestNextFrameListener(image -> drawn.complete(null)); + Runnable listener = () -> drawn.complete(null); + drawManager.registerEveryFrameListener(listener); + + try { + await(drawn, "a rendered frame"); + } finally { + drawManager.unregisterEveryFrameListener(listener); + } + } + + /** + * Get the client's own cycle counter. It advances once per client cycle of 20 ms, whatever the + * frame rate, and the client handles the input of a cycle within that cycle. + */ + int cycle() throws BridgeException { + return onClientThread(Client::getGameCycle); + } + + /** + * Wait until the client's cycle counter has advanced by the given number, looking once per frame, + * and then for the frame that the client draws next. A frame is not a unit of time: with an + * unlocked frame rate several frames pass within one cycle. + */ + void awaitCycles(int cycles) throws BridgeException { + int until = cycle() + cycles; + + while (cycle() < until) { + nextFrame(); + } - await(drawn, "a rendered frame"); + nextFrame(); } /** Stop following the game ticks. */ diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java index 938e464c..dd5351f7 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java @@ -20,12 +20,12 @@ final class GroundItemTarget extends Target { /** The menu actions that perform an option on a ground item. */ private static final Set FAMILY = EnumSet.of( - MenuAction.GROUND_ITEM_FIRST_OPTION, - MenuAction.GROUND_ITEM_SECOND_OPTION, - MenuAction.GROUND_ITEM_THIRD_OPTION, - MenuAction.GROUND_ITEM_FOURTH_OPTION, - MenuAction.GROUND_ITEM_FIFTH_OPTION, - MenuAction.EXAMINE_ITEM_GROUND); + MenuAction.GROUND_ITEM_FIRST_OPTION, + MenuAction.GROUND_ITEM_SECOND_OPTION, + MenuAction.GROUND_ITEM_THIRD_OPTION, + MenuAction.GROUND_ITEM_FOURTH_OPTION, + MenuAction.GROUND_ITEM_FIFTH_OPTION, + MenuAction.EXAMINE_ITEM_GROUND); /** The id of the item. */ private final int id; @@ -68,8 +68,10 @@ JsonObject describe() { Shape shape(Client client) { WorldView view = client.getTopLevelWorldView(); Tile tile = Scenes.tile(view, at.getX(), at.getY()); - List items = tile == null ? null : tile.getGroundItems(); + if (tile == null) return null; + + List items = tile.getGroundItems(); if (items == null || items.stream().noneMatch(item -> item.getId() == id)) return null; return Scenes.tilePolygon(client, view, at.getX() - view.getBaseX(), at.getY() - view.getBaseY()); @@ -79,8 +81,9 @@ Shape shape(Client client) { @Override boolean matches(MenuEntry entry, Client client) { WorldView view = client.getTopLevelWorldView(); - boolean sameTile = - entry.getParam0() == at.getX() - view.getBaseX() && entry.getParam1() == at.getY() - view.getBaseY(); + int sceneX = at.getX() - view.getBaseX(); + int sceneY = at.getY() - view.getBaseY(); + boolean sameTile = entry.getParam0() == sceneX && entry.getParam1() == sceneY; return FAMILY.contains(entry.getType()) && entry.getIdentifier() == id && sameTile && sameOption(entry); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java index 563aeea6..7987a06a 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java @@ -5,46 +5,55 @@ import java.awt.Point; import java.util.ArrayDeque; import java.util.Deque; +import net.rsprox.mcpbridge.ClickGuard.Outcome; import net.rsprox.mcpbridge.ClickGuard.Verdict; import net.rsprox.mcpbridge.GameAccess.Subscription; -import net.runelite.api.Client; import net.runelite.api.coords.WorldPoint; import net.runelite.api.events.MenuOptionClicked; /** * One interaction with a target, performed as a player performs it: the mouse is moved onto the - * target, the client builds its menu, and the entry is clicked, through the right-click menu when it - * is not the default. Each attempt is judged by the {@link ClickGuard} and repeated until the client - * performs the intended action or the deadline passes. The loop runs on the worker thread of the op. + * target, the client builds its menu, and the entry is clicked. The click is a left click when the + * entry looks like the default one, and goes through the right-click menu otherwise, or once the + * client has shown that a left click performs another entry. Each attempt is judged by the + * {@link ClickGuard} and repeated until the client performs the intended action or the deadline + * passes. The loop runs on the worker thread of the op. Every wait is counted in client cycles of + * 20 ms, which pass at the same rate whatever the frame rate is. */ final class Interaction { - /** The time the client gives a game cycle. */ - private static final int CYCLE_MS = 20; + /** The cycles an interaction with a target that is on screen may take: about three game ticks. */ + private static final int ON_SCREEN_CYCLES = 90; - /** The deadline for a target that is on screen: about three game ticks. */ - private static final int ON_SCREEN_DEADLINE_MS = 1_800; + /** The cycles an interaction may take once the camera had to be turned, which takes several ticks. */ + private static final int CAMERA_CYCLES = 300; - /** The deadline once the camera had to be turned, which takes the camera several ticks. */ - private static final int CAMERA_DEADLINE_MS = 6_000; + /** The longest wait for a game tick to start, in cycles: a little over one tick. */ + private static final int TICK_WAIT_CYCLES = 40; - /** The longest wait for a game tick to start, which is a little over one tick. */ - private static final int TICK_WAIT_MS = 800; + /** The cycles since the start of a game tick within which the wait for the next one is skipped. */ + private static final int RECENT_TICK_CYCLES = 5; - /** How recently a game tick must have started for the wait for one to be skipped. */ - private static final int RECENT_TICK_MS = 100; + /** The least cycles the client is given to report a click, which it handles on the cycle after the press. */ + private static final int CLICK_CYCLES = 5; - /** The number of frames the client is given to report a click. */ - private static final int CLICK_FRAMES = 3; + /** The least cycles the right-click menu is given to open after the press. */ + private static final int MENU_OPEN_CYCLES = 8; - /** The frames to wait for the right-click menu to open after the press. */ - private static final int MENU_OPEN_FRAMES = 8; + /** The cycles an opened menu is given to fill with its entries. */ + private static final int MENU_FILL_CYCLES = 1; - /** The number of attempts that the result and the failure keep the words of. */ + /** The cycles a menu that lacks the entry is given to catch up with the pointer before it is read again. */ + private static final int MENU_LAG_CYCLES = 1; + + /** The number of last attempts whose words are reported. */ private static final int KEPT = 5; /** The message when an interface blocks the game view. */ private static final String BLOCKED = "the client offers only Cancel; an open interface is blocking the game view"; + /** The words added to an attempt whose left click the client turned into another entry. */ + private static final String MENU_FROM_NOW = "; a left click performs another option here, so the menu is used next"; + /** The gateway to the client and the threads it must be used on. */ private final GameAccess game; @@ -72,12 +81,18 @@ final class Interaction { /** The number of reads at which the client offered more than Cancel. */ private int offeredMore; - /** The game cycle at which the interaction began. */ + /** The client cycle at which the interaction began. */ private int began; - /** The game cycle after which no further attempt is made. */ + /** The client cycle after which no further attempt is made. */ private int deadline; + /** Whether the client has shown that a left click on the target performs another entry than the intended one. */ + private boolean anotherPreferred; + + /** The canvas point of the press that the guard let through, or null until it let one through. */ + private Point pressed; + /** Create the interaction that performs the target's option through the given mouse. */ Interaction(GameAccess game, Mouse mouse, Target target) { this.game = game; @@ -88,8 +103,8 @@ final class Interaction { /** * Perform the interaction and describe it: the target, the number of attempts, the tick on which - * the client performed the action, the words of the last attempts and any turn of the camera. - * Throws {@code wrong_state} when the deadline passes first. + * the client performed the action, the canvas point that was pressed, the words of the last + * attempts and any turn of the camera. Throws {@code wrong_state} when the deadline passes first. */ JsonObject run() throws BridgeException { try (Subscription clicks = game.subscribe(MenuOptionClicked.class, guard::on)) { @@ -100,8 +115,8 @@ JsonObject run() throws BridgeException { }); awaitTick(); - began = cycle(); - deadline = began + ON_SCREEN_DEADLINE_MS / CYCLE_MS; + began = game.cycle(); + deadline = began + ON_SCREEN_CYCLES; while (true) { attempts++; @@ -109,9 +124,9 @@ JsonObject run() throws BridgeException { Verdict verdict = aim == null ? bringIntoView() : attempt(aim); remember(verdict.words); - if (verdict.passed) return result(verdict); + if (verdict.isPerformed()) return result(verdict); - if (cycle() >= deadline) throw expired(); + if (game.cycle() >= deadline) throw expired(); } } } @@ -122,42 +137,54 @@ JsonObject run() throws BridgeException { * has no ticks. */ private void awaitTick() throws BridgeException { - int start = cycle(); - if (game.tickCycle() >= start - RECENT_TICK_MS / CYCLE_MS) return; + int start = game.cycle(); + if (game.tickCycle() >= start - RECENT_TICK_CYCLES) return; - for (int i = 0; i < TICK_WAIT_MS / CYCLE_MS && game.tickCycle() < start; i++) { + while (game.tickCycle() < start && game.cycle() < start + TICK_WAIT_CYCLES) { game.nextFrame(); } } - /** Get the client's game cycle. */ - private int cycle() throws BridgeException { - return game.onClientThread(Client::getGameCycle); - } - /** * Move the mouse onto the aim point, read the menu and click the intended entry. The menu may lag - * the pointer by a frame, so a missing entry is read once more before the attempt counts as missed. + * the pointer, so a missing entry is read once more before the attempt counts as missed. */ private Verdict attempt(Point aim) throws BridgeException { mouse.hover(aim.x, aim.y); Offer offer = read(aim); if (offer.match < 0) { - game.nextFrame(); + game.awaitCycles(MENU_LAG_CYCLES); offer = read(aim); } String at = " at " + Scenes.words(aim.x, aim.y); + if (offer.match < 0) return missed(offer, offerWords(offer) + at); if (!offer.underMouse) return missed(offer, target.label() + " moved out from under the mouse" + at); - if (offer.isDefault() && !offer.open) return click(aim, "left-clicked " + offer.offered.get(offer.match) + at); + if (offer.isLast() && !offer.open && !anotherPreferred) return leftClick(aim, offer.offered.get(offer.match)); return throughMenu(aim, offer); } + /** + * Left-click the aim point, where the entry looks like the default one. Only the client's report + * says what a left click performs: the client sorts its entries after the menu is read, and a + * plugin may swap them. When it performs another entry while it offers the intended one, the + * following attempts go through the right-click menu. + */ + private Verdict leftClick(Point aim, String entry) throws BridgeException { + Verdict verdict = click(aim, "left-clicked " + entry + " at " + Scenes.words(aim.x, aim.y)); + + if (verdict.outcome != Outcome.ANOTHER_PREFERRED) return verdict; + + anotherPreferred = true; + + return verdict.worded(verdict.words + MENU_FROM_NOW); + } + /** * Open the right-click menu on the aim point, or use the one that is open, and click the intended * row. The row is found again in the menu as the client reports it after opening, since the @@ -186,40 +213,57 @@ private Verdict throughMenu(Point aim, Offer offer) throws BridgeException { } /** - * Wait for the right-click menu to open and read it once it has settled. The client opens the menu - * on its next game cycle, which can be several frames after the press, and fills it a frame later. + * Wait for the right-click menu to open and read it once it has filled. The client opens the menu + * on a cycle after the press and fills it on the one after that. */ private Offer awaitOpenMenu() throws BridgeException { + int until = game.cycle() + MENU_OPEN_CYCLES; Offer offer = read(null); - for (int i = 0; i < MENU_OPEN_FRAMES && !offer.open; i++) { + while (!offer.open && game.cycle() < until) { game.nextFrame(); offer = read(null); } if (!offer.open) return offer; - game.nextFrame(); + game.awaitCycles(MENU_FILL_CYCLES); return read(null); } - /** Left-click the point under the guard, and wait a few frames for the client to report the click. */ + /** + * Left-click the point under the guard, and give the client its cycles to report the click. The + * guard stays armed for all of them, so a click that the client handles late is still judged. + */ private Verdict click(Point at, String words) throws BridgeException { guard.arm(); mouse.click(at.x, at.y, Mouse.Button.LEFT); - for (int i = 0; i < CLICK_FRAMES && !guard.settled(); i++) { + int until = game.cycle() + CLICK_CYCLES; + while (!guard.isSettled() && game.cycle() < until) { game.nextFrame(); } - Verdict verdict = guard.disarm("the client reported no click within " + CLICK_FRAMES + " frames"); - String outcome = words + "; " + verdict.words; + Verdict verdict = game.onClientThread(client -> guard.disarm()); + if (verdict == null) return unreported(words); + + if (verdict.isPerformed()) pressed = at; - return verdict.passed ? Verdict.passed(outcome, verdict.tick) : Verdict.missed(outcome); + return verdict.worded(words + "; " + verdict.words); } - /** Record a missed attempt, closing the menu when it is open so that the next attempt starts afresh. */ + /** + * Build the verdict of a click that the client did not report, once the cycle in which the guard + * was disarmed has passed, so that the next click is never made in that same cycle. + */ + private Verdict unreported(String words) throws BridgeException { + game.awaitCycles(1); + + return Verdict.missed(words + "; the client reported no click within " + CLICK_CYCLES + " cycles"); + } + + /** Build the verdict of a missed attempt, after moving the mouse off an open menu so that it closes. */ private Verdict missed(Offer offer, String words) throws BridgeException { if (offer.open) { Point away = offer.away(); @@ -234,7 +278,7 @@ private Verdict missed(Offer offer, String words) throws BridgeException { * given, and count the reads and those that offered more than Cancel. */ private Offer read(Point aim) throws BridgeException { - Offer offer = game.onClientThread(client -> new Offer(client, target, aim)); + Offer offer = game.onClientThread(client -> Offer.read(client, target, aim)); reads++; if (!offer.onlyCancel) offeredMore++; @@ -251,11 +295,11 @@ private static String offerWords(Offer offer) { private Verdict bringIntoView() throws BridgeException { WorldPoint tile = game.onClientThread(target::tile); String words = "no part of " + target.label() + " is on screen"; - game.nextFrame(); + game.awaitCycles(1); if (tile == null) return Verdict.missed(words); - deadline = Math.max(deadline, began + CAMERA_DEADLINE_MS / CYCLE_MS); + deadline = Math.max(deadline, began + CAMERA_CYCLES); return Verdict.missed(words + "; " + game.onClientThread(client -> camera.toward(client, tile))); } @@ -272,6 +316,7 @@ private JsonObject result(Verdict verdict) { JsonObject out = target.describe(); out.addProperty("attempts", attempts); out.addProperty("tick", verdict.tick); + out.add("pressed", point(pressed)); JsonArray words = new JsonArray(); tried.forEach(words::add); out.add("tried", words); @@ -281,6 +326,15 @@ private JsonObject result(Verdict verdict) { return out; } + /** Build the JSON array of the x and the y of the canvas point. */ + private static JsonArray point(Point point) { + JsonArray out = new JsonArray(); + out.add(point.x); + out.add(point.y); + + return out; + } + /** Build the failure for a deadline that passed, with what happened at the last attempt. */ private BridgeException expired() { String last = reads > 0 && offeredMore == 0 ? BLOCKED : tried.peekLast(); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java index 16bc7706..6718b133 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Mouse.java @@ -7,9 +7,16 @@ /** The real mouse of the client: synthetic events on its canvas, which the client treats as a player's input. */ final class Mouse { - /** The pause between the press and the release of a click, as a player's finger takes. */ + /** The milliseconds by which the timestamp of a release follows that of its press. No time passes between them. */ private static final int CLICK_HOLD_MS = 30; + /** The cycles the client is given to take a new pointer position and build its menu for it. */ + private static final int FOLLOW_CYCLES = 2; + + /** The refusal of input in stretched mode. */ + private static final String STRETCHED = "stretched mode is on, in which a mouse position is not a canvas position"; + + /** A button of the mouse, with the names that AWT gives it. */ enum Button { /** The left button. */ LEFT(MouseEvent.BUTTON1, InputEvent.BUTTON1_DOWN_MASK), @@ -42,7 +49,7 @@ enum Button { * Move the mouse to the canvas position. While the client tracks the pointer as off the canvas, it * is first brought onto it, as happens when a pointer enters a window. */ - void move(int x, int y) throws BridgeException { + private void move(int x, int y) throws BridgeException { boolean outside = game.onClientThread(Mouse::pointerOutside); game.input(canvas -> { long now = System.currentTimeMillis(); @@ -54,12 +61,12 @@ void move(int x, int y) throws BridgeException { } /** - * Move the mouse to the canvas position and wait for the frame that the client draws with it there. - * The client builds the menu for the scene while it draws, from where the mouse is at that time. + * Move the mouse to the canvas position and wait until the client has built its menu for it. The + * client takes the position on its next cycle and builds the menu while it draws the frame after. */ void hover(int x, int y) throws BridgeException { move(x, y); - game.nextFrame(); + game.awaitCycles(FOLLOW_CYCLES); } /** Press and release the button at the canvas position, where the mouse must already be. */ @@ -83,9 +90,7 @@ void click(int x, int y, Button button) throws BridgeException { * in stretched mode, where the canvas is drawn at another size than its coordinates. */ static void requireUnstretched(Client client) throws BridgeException { - String refusal = "the client is in stretched mode, where a mouse position is not a canvas position"; - - if (client.isStretchedEnabled()) throw new BridgeException("wrong_state", refusal); + if (client.isStretchedEnabled()) throw new BridgeException("wrong_state", STRETCHED); } /** Determine if the client tracks the pointer as off the canvas, which it reports as a negative position. */ diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java index 4683d0f7..e4992f4e 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java @@ -14,12 +14,12 @@ final class NpcTarget extends Target { /** The menu actions that perform an option on an NPC. */ private static final Set FAMILY = EnumSet.of( - MenuAction.NPC_FIRST_OPTION, - MenuAction.NPC_SECOND_OPTION, - MenuAction.NPC_THIRD_OPTION, - MenuAction.NPC_FOURTH_OPTION, - MenuAction.NPC_FIFTH_OPTION, - MenuAction.EXAMINE_NPC); + MenuAction.NPC_FIRST_OPTION, + MenuAction.NPC_SECOND_OPTION, + MenuAction.NPC_THIRD_OPTION, + MenuAction.NPC_FOURTH_OPTION, + MenuAction.NPC_FIFTH_OPTION, + MenuAction.EXAMINE_NPC); /** The index of the NPC. */ private final int index; diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java index 8242e17f..89aaf2bd 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java @@ -3,7 +3,6 @@ import com.google.gson.JsonObject; import java.awt.Shape; import java.util.EnumSet; -import java.util.List; import java.util.Set; import net.runelite.api.Client; import net.runelite.api.GameObject; @@ -22,12 +21,12 @@ final class ObjectTarget extends Target { /** The menu actions that perform an option on an object. */ private static final Set FAMILY = EnumSet.of( - MenuAction.GAME_OBJECT_FIRST_OPTION, - MenuAction.GAME_OBJECT_SECOND_OPTION, - MenuAction.GAME_OBJECT_THIRD_OPTION, - MenuAction.GAME_OBJECT_FOURTH_OPTION, - MenuAction.GAME_OBJECT_FIFTH_OPTION, - MenuAction.EXAMINE_OBJECT); + MenuAction.GAME_OBJECT_FIRST_OPTION, + MenuAction.GAME_OBJECT_SECOND_OPTION, + MenuAction.GAME_OBJECT_THIRD_OPTION, + MenuAction.GAME_OBJECT_FOURTH_OPTION, + MenuAction.GAME_OBJECT_FIFTH_OPTION, + MenuAction.EXAMINE_OBJECT); /** The object's own id. */ private final int id; @@ -102,9 +101,11 @@ WorldPoint tile(Client client) { /** Find the object on its tile of origin, or null while it is not there. */ private TileObject find(Client client) { Tile tile = Scenes.tile(client.getTopLevelWorldView(), origin.getX(), origin.getY()); - Point at = tile == null ? null : tile.getSceneLocation(); + if (tile == null) return null; - for (TileObject object : tile == null ? List.of() : Scenes.objects(tile)) { + Point at = tile.getSceneLocation(); + + for (TileObject object : Scenes.objects(tile)) { if (object.getId() == id && Scenes.origin(object, tile).equals(at)) return object; } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java index a7c06c06..6ab7e1fd 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java @@ -5,6 +5,7 @@ import java.awt.Rectangle; import java.awt.Shape; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import net.runelite.api.Client; import net.runelite.api.Menu; @@ -13,18 +14,20 @@ /** * What the client's menu offered at one moment, read on the client thread and kept as plain values: - * the entries in the client's array order, where the last is the one a left click performs, whether - * the aimed-at target was still under the mouse, and the row geometry of the menu when it is open. + * the entries in the client's array order, whether the aimed-at target was still under the mouse, and + * the row geometry of the menu when it is open. A left click usually performs the last entry, but the + * client sorts the entries after this read and a plugin may swap them, so only the client's own + * report of a click says what it performed. */ final class Offer { - /** - * The client's menu layout: a header of this height above the rows, then one row per entry, drawn - * top to bottom in reverse array order. The row height is derived from the menu height and the - * number of entries, and this value is used when that derivation gives nonsense. - */ + /** The height of the header that the client draws above the rows of its menu. */ private static final int HEADER = 19; - /** The row height of the client's menu layout, as a fallback. */ + /** + * The height of a row of the client's menu, one per entry, drawn top to bottom in reverse array + * order. It is used when the height derived from the menu's height and its number of entries is + * not a sane one. + */ private static final int ROW = 15; /** The least row height that counts as a sane derivation. */ @@ -40,7 +43,7 @@ final class Offer { final boolean open; /** The entries as "option target" with the colour tags stripped, in the client's array order. */ - final List offered = new ArrayList<>(); + final List offered; /** The index of the entry that performs the intended action, or -1 when none does. */ final int match; @@ -60,48 +63,72 @@ final class Offer { /** The size of the canvas. */ private final Rectangle canvas; + /** Create the offer of the entries, of which the one at the index performs the intended action. */ + private Offer(Client client, MenuEntry[] entries, int match, boolean underMouse) { + Menu menu = client.getMenu(); + Canvas surface = client.getCanvas(); + this.open = client.isMenuOpen(); + this.offered = words(entries); + this.match = match; + this.onlyCancel = Arrays.stream(entries).allMatch(entry -> entry.getType() == MenuAction.CANCEL); + this.underMouse = underMouse; + this.bounds = new Rectangle(menu.getMenuX(), menu.getMenuY(), menu.getMenuWidth(), menu.getMenuHeight()); + this.scroll = client.getMenuScroll(); + this.canvas = new Rectangle(0, 0, surface.getWidth(), surface.getHeight()); + } + /** * Read the menu the client has built, looking for the entry that performs the option on the * target, and whether the target still lies under the aimed-at point, if one is given. */ - Offer(Client client, Target target, Point aim) { - Menu menu = client.getMenu(); - MenuEntry[] entries = menu.getMenuEntries(); - int found = -1; - boolean cancels = true; + static Offer read(Client client, Target target, Point aim) { + MenuEntry[] entries = client.getMenu().getMenuEntries(); + + return new Offer(client, entries, lastMatch(entries, target, client), isUnder(client, target, aim)); + } + + /** Get the index of the last entry that performs the target's option on the target, or -1 when none does. */ + private static int lastMatch(MenuEntry[] entries, Target target, Client client) { + for (int i = entries.length - 1; i >= 0; i--) { + if (target.matches(entries[i], client)) return i; + } + + return -1; + } + + /** Determine if the target's outline holds the aimed-at point, which a read without an aim takes as given. */ + private static boolean isUnder(Client client, Target target, Point aim) { + if (aim == null) return true; + + Shape shape = target.shape(client); - for (int i = 0; i < entries.length; i++) { - offered.add(words(entries[i])); - cancels &= entries[i].getType() == MenuAction.CANCEL; + return shape != null && shape.contains(aim); + } + + /** Write each entry as its option and its target with the colour tags stripped, in the given order. */ + private static List words(MenuEntry[] entries) { + List out = new ArrayList<>(); - if (target.matches(entries[i], client)) found = i; + for (MenuEntry entry : entries) { + out.add(words(entry)); } - Shape shape = aim == null ? null : target.shape(client); - Canvas surface = client.getCanvas(); - this.open = client.isMenuOpen(); - this.match = found; - this.onlyCancel = cancels; - this.underMouse = aim == null || (shape != null && shape.contains(aim)); - this.bounds = new Rectangle(menu.getMenuX(), menu.getMenuY(), menu.getMenuWidth(), menu.getMenuHeight()); - this.scroll = client.getMenuScroll(); - this.canvas = new Rectangle(0, 0, surface.getWidth(), surface.getHeight()); + return out; } - /** Determine if the matching entry is the one a left click performs with the menu closed. */ - boolean isDefault() { + /** Determine if the matching entry is the last of the array, which is the one a left click usually performs. */ + boolean isLast() { return match >= 0 && match == offered.size() - 1; } /** Get the canvas point in the vertical middle of the matching entry's row of the open menu. */ Point row() { - int row = offered.size() - 1 - match - scroll; int height = rowHeight(); - return new Point(bounds.x + bounds.width / 2, bounds.y + HEADER + height * row + height / 2); + return new Point(bounds.x + bounds.width / 2, bounds.y + HEADER + height * rowNumber() + height / 2); } - /** Get the row number of the matching entry, counted from the top of the open menu. */ + /** Get the row number of the matching entry, counted from the top of the open menu, where the first is 0. */ int rowNumber() { return offered.size() - 1 - match - scroll; } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 1d4bfbb1..94a000d6 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -21,6 +21,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.imageio.ImageIO; @@ -39,8 +40,8 @@ final class Ops { /** The message for a widget reference that does not have that shape. */ private static final String WIDGET_REF_SHAPE = "widget must look like \"558:7\" or \"558:7[3]\""; - /** The deepest level of nested widgets that a walk descends to. */ - private static final int WIDGET_DEPTH_LIMIT = 12; + /** The deepest level of nested widgets that a walk or a search descends to. */ + static final int WIDGET_DEPTH_LIMIT = 12; /** The pause between two reads of the game state while a login is awaited. */ private static final int LOGIN_POLL_MS = 100; @@ -48,6 +49,7 @@ final class Ops { /** The message for a login that put the client back on the login screen. */ private static final String LOGIN_REFUSED = "the login was refused; the client is back on the login screen"; + /** One operation that rsprox can ask for. */ interface Op { /** Run the op with the arguments that rsprox forwarded. */ JsonElement run(JsonObject args) throws BridgeException; @@ -62,20 +64,48 @@ interface Op { /** The ops by the name rsprox calls them. */ private final Map table = new HashMap<>(); + /** The lock that an op holds for as long as it drives the one mouse and keyboard of the client. */ + private final Object input = new Object(); + /** Create the op table for the given game. */ Ops(GameAccess game) { this.game = game; this.mouse = new Mouse(game); - table.put("state", this::state); - table.put("widgets", this::widgets); - table.put("vars", this::vars); - table.put("screenshot", this::screenshot); - table.put("click", this::click); - table.put("type", this::type); - table.put("login", this::login); - table.put("entities", new Entities(game)); - table.put("interact", new Interact(game, mouse)); - table.put("camera", new Camera(game)); + reading("state", this::state); + reading("widgets", this::widgets); + reading("vars", this::vars); + reading("screenshot", this::screenshot); + reading("login", this::login); + reading("entities", new Entities(game)); + driving("click", this::click); + driving("type", this::type); + driving("interact", new Interact(game, mouse)); + driving("camera", Camera::turns, new Camera(game)); + } + + /** Register an op that produces no input, which runs beside any other op. */ + private void reading(String name, Op op) { + table.put(name, op); + } + + /** Register an op that produces input, which runs alone among such ops from its start to its end. */ + private void driving(String name, Op op) { + driving(name, args -> true, op); + } + + /** Register an op that produces input for the arguments that pass the test, and only reads for the others. */ + private void driving(String name, Predicate drives, Op op) { + table.put(name, args -> drives.test(args) ? alone(op, args) : op.run(args)); + } + + /** + * Run the op while no other op produces input. Two ops that move the pointer at once would each + * click where the other left it, and the click guard of one would judge the click of the other. + */ + private JsonElement alone(Op op, JsonObject args) throws BridgeException { + synchronized (input) { + return op.run(args); + } } /** Run the named op. Throws {@code bad_args} for a name that is not in the table. */ @@ -286,6 +316,7 @@ private JsonElement vars(JsonObject args) throws BridgeException { }); } + /** A read of one kind of client variable. */ private interface VarReader { /** Read the variable with the given id. */ JsonElement read(int id); @@ -347,19 +378,7 @@ private JsonElement screenshot(JsonObject args) throws BridgeException { /** Click the canvas at the given coordinates, or at the centre of the given widget. */ private JsonElement click(JsonObject args) throws BridgeException { boolean right = "right".equals(optionalString(args, "button")); - String widget = optionalString(args, "widget"); - Integer x = optionalInt(args, "x"); - Integer y = optionalInt(args, "y"); - Point point; - - if (widget != null) { - point = game.onClientThread(client -> centre(visibleWidget(client, widget).getBounds())); - } else if (x != null && y != null) { - point = new Point(x, y); - } else { - throw new BridgeException("bad_args", "pass either x and y, or widget"); - } - + Point point = clickPoint(args); mouse.hover(point.x, point.y); mouse.click(point.x, point.y, right ? Mouse.Button.RIGHT : Mouse.Button.LEFT); @@ -370,6 +389,22 @@ private JsonElement click(JsonObject args) throws BridgeException { return out; } + /** + * Get the canvas point to click: the centre of the widget when one is named, and the x and y + * otherwise. Throws {@code bad_args} when neither is given. + */ + private Point clickPoint(JsonObject args) throws BridgeException { + String widget = optionalString(args, "widget"); + if (widget != null) return game.onClientThread(client -> centre(visibleWidget(client, widget).getBounds())); + + Integer x = optionalInt(args, "x"); + Integer y = optionalInt(args, "y"); + + if (x == null || y == null) throw new BridgeException("bad_args", "pass either x and y, or widget"); + + return new Point(x, y); + } + /** * Get the referenced widget. Throws {@code bad_args} for a malformed reference and * {@code not_found} for a widget that does not exist or is not visible. @@ -471,9 +506,7 @@ private static BridgeException neverLoaded(GameState state) { private void submitCredentials(String username, String password) throws BridgeException { game.onClientThread(client -> { GameState state = client.getGameState(); - String refusal = "the client is not on the login screen: " + state; - - if (state != GameState.LOGIN_SCREEN) throw new BridgeException("wrong_state", refusal); + if (state != GameState.LOGIN_SCREEN) throw notOnLoginScreen(state); client.setUsername(username); client.setPassword(password); @@ -483,6 +516,11 @@ private void submitCredentials(String username, String password) throws BridgeEx }); } + /** Build the refusal of a login for a client that is in the given state and not on the login screen. */ + private static BridgeException notOnLoginScreen(GameState state) { + return new BridgeException("wrong_state", "the client is not on the login screen: " + state); + } + /** * Wait until the client is in the game. Throws {@code wrong_state} when the login is refused and * {@code timeout} when the deadline passes first. diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java index ea69f450..eb9395b1 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java @@ -14,14 +14,14 @@ final class PlayerTarget extends Target { /** The menu actions that perform an option on a player. */ private static final Set FAMILY = EnumSet.of( - MenuAction.PLAYER_FIRST_OPTION, - MenuAction.PLAYER_SECOND_OPTION, - MenuAction.PLAYER_THIRD_OPTION, - MenuAction.PLAYER_FOURTH_OPTION, - MenuAction.PLAYER_FIFTH_OPTION, - MenuAction.PLAYER_SIXTH_OPTION, - MenuAction.PLAYER_SEVENTH_OPTION, - MenuAction.PLAYER_EIGHTH_OPTION); + MenuAction.PLAYER_FIRST_OPTION, + MenuAction.PLAYER_SECOND_OPTION, + MenuAction.PLAYER_THIRD_OPTION, + MenuAction.PLAYER_FOURTH_OPTION, + MenuAction.PLAYER_FIFTH_OPTION, + MenuAction.PLAYER_SIXTH_OPTION, + MenuAction.PLAYER_SEVENTH_OPTION, + MenuAction.PLAYER_EIGHTH_OPTION); /** The index of the player. */ private final int index; diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java index 64d592ad..a9dffa45 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java @@ -22,7 +22,7 @@ final class Scenes { /** The options of a ground item by position. The RuneLite API exposes none, and the client takes with the third. */ static final String[] GROUND_ITEM_OPTIONS = {null, null, "Take", null, null}; - /** Not for instances. */ + /** Prevent the creation of instances. */ private Scenes() { // } @@ -75,12 +75,14 @@ static ObjectComposition shown(Client client, int id) { return composition.getImpostorIds() == null ? composition : composition.getImpostor(); } - /** Get the names of the options that are offered, in order. */ + /** Get the names of the options that are offered, in order and without their colour tags. */ static List offered(String[] options) { List out = new ArrayList<>(); for (String option : options == null ? new String[0] : options) { - if (option != null && !option.isEmpty()) out.add(option); + String name = Offer.untagged(option); + + if (!name.isEmpty()) out.add(name); } return out; @@ -100,7 +102,7 @@ static Polygon tilePolygon(Client client, WorldView view, int sceneX, int sceneY return Perspective.getCanvasTilePoly(client, LocalPoint.fromScene(sceneX, sceneY, view)); } - /** Write the world tile as its coordinates in brackets. */ + /** Write the two coordinates in brackets, whether they name a world tile or a canvas point. */ static String words(int x, int y) { return "(" + x + ", " + y + ")"; } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java index 502abc57..7f4539c9 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Target.java @@ -51,10 +51,15 @@ Point aim(Client client, int attempt) { /** Determine if the entry's option is exactly the option, ignoring case and colour tags. */ boolean sameOption(MenuEntry entry) { - return option.equalsIgnoreCase(Offer.untagged(entry.getOption())); + return isSameOption(option, entry.getOption()); } - /** Start the description with the packet prefix, the kind and, for the result's order, nothing else yet. */ + /** Determine if the two options or actions are the same, ignoring case and colour tags. */ + static boolean isSameOption(String one, String other) { + return Offer.untagged(one).equalsIgnoreCase(Offer.untagged(other)); + } + + /** Start the description of a target with the prefix of the packet to expect and its kind. */ static JsonObject described(String expect, String kind) { JsonObject out = new JsonObject(); out.addProperty("expect", expect); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java index 2051c321..3d7dbcc2 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java @@ -1,6 +1,5 @@ package net.rsprox.mcpbridge; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.util.ArrayList; import java.util.Arrays; @@ -14,7 +13,6 @@ import net.runelite.api.Player; import net.runelite.api.Point; import net.runelite.api.Tile; -import net.runelite.api.TileItem; import net.runelite.api.TileObject; import net.runelite.api.WorldView; import net.runelite.api.coords.WorldPoint; @@ -27,15 +25,15 @@ * have, so both are refused before anything is clicked. */ final class Targets { - /** The arguments that each kind of target needs. */ - private static final Map> REQUIRED = Map.of( - "npc", List.of("index", "option"), - "object", List.of("id", "x", "y", "option"), - "ground_item", List.of("id", "x", "y", "option"), - "player", List.of("index", "option"), - "widget", List.of("widget", "option"), - "dialog", List.of("option"), - "tile", List.of("x", "y")); + /** The kinds of target by name, each with the arguments that name it and the way to find it. */ + private static final Map KINDS = Map.of( + "npc", new Kind(Targets::npc, "index", "option"), + "object", new Kind(Targets::object, "id", "x", "y", "option"), + "ground_item", new Kind(Targets::groundItem, "id", "x", "y", "option"), + "player", new Kind(Targets::player, "index", "option"), + "widget", new Kind(Targets::widget, "widget", "option"), + "dialog", new Kind(Targets::dialog, "option"), + "tile", new Kind(Targets::walk, "x", "y")); /** The option that every NPC, object and ground item has after its own five. */ private static final String EXAMINE = "Examine"; @@ -52,16 +50,34 @@ final class Targets { /** The text of the widget that continues a dialog. */ private static final String CONTINUE_TEXT = "Click here to continue"; - /** The deepest level of nested widgets that a search descends to. */ - private static final int WIDGET_DEPTH_LIMIT = 12; - - /** Not for instances. */ + /** Prevent the creation of instances. */ private Targets() { // } + /** The way to find the target of one kind. */ + private interface Finder { + /** Find the target that the arguments name, with the options the client says it has. */ + Candidate find(Client client, JsonObject args, String option) throws BridgeException; + } + + /** One kind of target: the way to find it and the arguments it needs. */ + private static final class Kind { + /** The way to find a target of the kind. */ + final Finder finder; + + /** The names of the arguments that a target of the kind needs. */ + final List required; + + /** Create the kind that the finder finds from the required arguments. */ + Kind(Finder finder, String... required) { + this.finder = finder; + this.required = List.of(required); + } + } + /** A target as found, with the options the client says it has. */ - private static final class Found { + private static final class Candidate { /** The target. */ final Target target; @@ -69,10 +85,20 @@ private static final class Found { final List options; /** Create the record of a found target. */ - Found(Target target, List options) { + Candidate(Target target, List options) { this.target = target; this.options = options; } + + /** Determine if the target offers the option that is to be performed on it. */ + boolean offersOption() { + return options.stream().anyMatch(offered -> Target.isSameOption(offered, target.option)); + } + + /** Build the failure for a target that does not offer the option. */ + BridgeException noOption() { + return notFound(target.label() + " has no option '" + target.option + "'. It offers: " + options); + } } /** Check that the kind of target has its arguments. Throws {@code bad_args} for the first that is missing. */ @@ -88,13 +114,9 @@ static void requireIdentity(JsonObject args) throws BridgeException { /** Check that the kind of target has those of its arguments that pass the test. Throws {@code bad_args}. */ private static void require(JsonObject args, Predicate wanted) throws BridgeException { String kind = args.get("target").getAsString(); - List required = REQUIRED.get(kind); - - if (required == null) throw new BridgeException("bad_args", "unknown target '" + kind + "'"); - for (String name : required) { - JsonElement value = args.get(name); - boolean missing = wanted.test(name) && (value == null || value.isJsonNull()); + for (String name : KINDS.get(kind).required) { + boolean missing = wanted.test(name) && !args.has(name); if (missing) throw new BridgeException("bad_args", "target " + kind + " needs '" + name + "'"); } @@ -105,13 +127,10 @@ private static void require(JsonObject args, Predicate wanted) throws Br * {@code not_found} for a target the client does not have or an option it does not offer. */ static Target resolve(Client client, JsonObject args) throws BridgeException { - Found found = find(client, args); - Target target = found.target; - String refusal = target.label() + " has no option '" + target.option + "'. It offers: " + found.options; + Candidate found = find(client, args); + if (!found.offersOption()) throw found.noOption(); - if (found.options.stream().noneMatch(target.option::equalsIgnoreCase)) throw notFound(refusal); - - return target; + return found.target; } /** @@ -123,25 +142,15 @@ static Target locate(Client client, JsonObject args) throws BridgeException { } /** Find the target of the kind the arguments name, with the options the client says it has. */ - private static Found find(Client client, JsonObject args) throws BridgeException { + private static Candidate find(Client client, JsonObject args) throws BridgeException { String option = Ops.nullToEmpty(Ops.optionalString(args, "option")); - switch (args.get("target").getAsString()) { - case "npc": - return npc(client, args.get("index").getAsInt(), option); - case "player": - return player(client, args.get("index").getAsInt(), option); - case "widget": - return widget(client, args.get("widget").getAsString(), option); - case "dialog": - return dialog(client, option); - case "object": - return object(client, args.get("id").getAsInt(), tile(client, args), option); - case "ground_item": - return groundItem(client, args.get("id").getAsInt(), tile(client, args), option); - default: - return new Found(new TileTarget(tile(client, args)), List.of(TileTarget.WALK_HERE)); - } + return KINDS.get(args.get("target").getAsString()).finder.find(client, args, option); + } + + /** Find the tile to walk to, on which the client offers to walk. */ + private static Candidate walk(Client client, JsonObject args, String option) throws BridgeException { + return new Candidate(new TileTarget(tile(client, args)), List.of(TileTarget.WALK_HERE)); } /** Get the world tile that the arguments name. Throws {@code not_found} for one outside the loaded scene. */ @@ -149,15 +158,15 @@ private static WorldPoint tile(Client client, JsonObject args) throws BridgeExce int x = args.get("x").getAsInt(); int y = args.get("y").getAsInt(); WorldView view = client.getTopLevelWorldView(); - String refusal = "tile " + Scenes.words(x, y) + " is not in the loaded scene"; - if (!Scenes.isLoaded(view, x, y)) throw notFound(refusal); + if (!Scenes.isLoaded(view, x, y)) throw notFound("tile " + Scenes.words(x, y) + " is not in the loaded scene"); return new WorldPoint(x, y, view.getPlane()); } /** Find the NPC with the index in the client's list of NPCs. */ - private static Found npc(Client client, int index, String option) throws BridgeException { + private static Candidate npc(Client client, JsonObject args, String option) throws BridgeException { + int index = args.get("index").getAsInt(); NPC npc = client.getTopLevelWorldView().npcs().byIndex(index); NPCComposition composition = npc == null ? null : npc.getTransformedComposition(); @@ -165,56 +174,81 @@ private static Found npc(Client client, int index, String option) throws BridgeE String name = Ops.nullToEmpty(composition.getName()); - return new Found(new NpcTarget(index, npc.getId(), name, option), withExamine(composition.getActions())); + return new Candidate(new NpcTarget(index, npc.getId(), name, option), withExamine(composition.getActions())); } /** Find the player with the index in the client's list of players. */ - private static Found player(Client client, int index, String option) throws BridgeException { + private static Candidate player(Client client, JsonObject args, String option) throws BridgeException { + int index = args.get("index").getAsInt(); Player player = client.getTopLevelWorldView().players().byIndex(index); + if (player == null) throw notFound("no player with index " + index + " is in the client's view"); String[] options = Arrays.copyOf(client.getPlayerOptions(), PLAYER_OPTIONS); + PlayerTarget target = new PlayerTarget(index, Ops.nullToEmpty(player.getName()), option); - return new Found(new PlayerTarget(index, Ops.nullToEmpty(player.getName()), option), Scenes.offered(options)); + return new Candidate(target, Scenes.offered(options)); } /** * Find the visible widget with the reference. Besides its own ops, the client may offer to continue * a dialog on it. */ - private static Found widget(Client client, String ref, String option) throws BridgeException { + private static Candidate widget(Client client, JsonObject args, String option) throws BridgeException { + String ref = args.get("widget").getAsString(); Widget widget = Ops.visibleWidget(client, ref); List options = new ArrayList<>(Scenes.offered(widget.getActions())); options.add(CONTINUE); - return new Found(new WidgetTarget(ref, widget.getId(), widget.getIndex(), option), options); + return new Candidate(new WidgetTarget(ref, widget.getId(), widget.getIndex(), option), options); } /** * Find the widget of the open dialog that the option names: the one that continues it for * "continue", or the numbered or worded choice of a dialog with options. */ - private static Found dialog(Client client, String option) throws BridgeException { - if (option.equalsIgnoreCase(CONTINUE)) return continueWidget(client); + private static Candidate dialog(Client client, JsonObject args, String option) throws BridgeException { + if (Target.isSameOption(option, CONTINUE)) return continueWidget(client); + + List choices = choices(client); + + for (Widget choice : choices) { + if (names(option, choice) && Ops.isVisible(choice)) return found(choice); + } + + throw notFound("no dialog option '" + option + "' is shown. It offers: " + numbered(choices)); + } + /** Get the widgets of the choices of the open dialog, or none while no dialog with options is open. */ + private static List choices(Client client) { Widget options = client.getWidget(ComponentID.DIALOG_OPTION_OPTIONS); - Widget[] choices = options == null || options.isHidden() ? null : options.getDynamicChildren(); - List texts = new ArrayList<>(); + if (options == null || options.isHidden()) return List.of(); + + Widget[] choices = options.getDynamicChildren(); + + return choices == null ? List.of() : Arrays.asList(choices); + } + + /** Determine if the option names the choice, by its text or by its number. */ + private static boolean names(String option, Widget choice) { + return Target.isSameOption(option, choice.getText()) || Integer.toString(choice.getIndex()).equals(option); + } - for (Widget choice : choices == null ? new Widget[0] : choices) { - String text = Ops.nullToEmpty(choice.getText()); - boolean chosen = text.equalsIgnoreCase(option) || Integer.toString(choice.getIndex()).equals(option); + /** Write each choice that has a text as its number and its text. The first child is the title of the dialog. */ + private static List numbered(List choices) { + List out = new ArrayList<>(); - if (chosen && Ops.isVisible(choice)) return found(choice); + for (Widget choice : choices) { + String text = Offer.untagged(choice.getText()); - if (!text.isEmpty() && choice.getIndex() > 0) texts.add(choice.getIndex() + ". " + text); + if (!text.isEmpty() && choice.getIndex() > 0) out.add(choice.getIndex() + ". " + text); } - throw notFound("no dialog option '" + option + "' is shown. It offers: " + texts); + return out; } /** Find the visible widget that continues a dialog. Throws {@code not_found} while no dialog waits. */ - private static Found continueWidget(Client client) throws BridgeException { + private static Candidate continueWidget(Client client) throws BridgeException { for (Widget root : client.getWidgetRoots()) { Widget widget = firstVisible(root, Targets::continues, 0); if (widget != null) return found(widget); @@ -229,15 +263,15 @@ private static boolean continues(Widget widget) { } /** Build the found record of a dialog widget, which the client offers to continue. */ - private static Found found(Widget widget) { + private static Candidate found(Widget widget) { WidgetTarget target = new WidgetTarget(Ops.ref(widget), widget.getId(), widget.getIndex(), CONTINUE); - return new Found(target, List.of(CONTINUE)); + return new Candidate(target, List.of(CONTINUE)); } /** Find the first visible widget under the root, itself included, that passes the test. */ private static Widget firstVisible(Widget root, Predicate test, int depth) { - if (root == null || root.isHidden() || depth > WIDGET_DEPTH_LIMIT) return null; + if (root == null || root.isHidden() || depth > Ops.WIDGET_DEPTH_LIMIT) return null; if (test.test(root)) return root; @@ -254,40 +288,57 @@ private static Widget firstVisible(Widget root, Predicate test, int dept } /** Find the object that has the id, or shows as it, on the world tile. */ - private static Found object(Client client, int id, WorldPoint at, String option) throws BridgeException { - WorldView view = client.getTopLevelWorldView(); - Tile tile = Scenes.tile(view, at.getX(), at.getY()); + private static Candidate object(Client client, JsonObject args, String option) throws BridgeException { + int id = args.get("id").getAsInt(); + WorldPoint at = tile(client, args); + Tile tile = Scenes.tile(client.getTopLevelWorldView(), at.getX(), at.getY()); - for (TileObject object : tile == null ? List.of() : Scenes.objects(tile)) { + if (tile == null) throw noObject(id, at); + + for (TileObject object : Scenes.objects(tile)) { ObjectComposition shown = Scenes.shown(client, object.getId()); if (shown == null || (object.getId() != id && shown.getId() != id)) continue; - Point origin = Scenes.origin(object, tile); - int x = view.getBaseX() + origin.getX(); - int y = view.getBaseY() + origin.getY(); - WorldPoint world = new WorldPoint(x, y, at.getPlane()); - ObjectTarget target = new ObjectTarget(object.getId(), Ops.nullToEmpty(shown.getName()), world, option); + String name = Ops.nullToEmpty(shown.getName()); + ObjectTarget target = new ObjectTarget(object.getId(), name, origin(client, object, tile), option); - return new Found(target, withExamine(shown.getActions())); + return new Candidate(target, withExamine(shown.getActions())); } - throw notFound("no object " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); + throw noObject(id, at); + } + + /** Get the world tile of origin of the object on the tile, on the plane in view. */ + private static WorldPoint origin(Client client, TileObject object, Tile tile) { + WorldView view = client.getTopLevelWorldView(); + Point origin = Scenes.origin(object, tile); + + return new WorldPoint(view.getBaseX() + origin.getX(), view.getBaseY() + origin.getY(), view.getPlane()); + } + + /** Build the failure for an object that is not on the world tile. */ + private static BridgeException noObject(int id, WorldPoint at) { + return notFound("no object " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); } /** Find the item with the id that lies on the world tile. */ - private static Found groundItem(Client client, int id, WorldPoint at, String option) throws BridgeException { + private static Candidate groundItem(Client client, JsonObject args, String option) throws BridgeException { + int id = args.get("id").getAsInt(); + WorldPoint at = tile(client, args); Tile tile = Scenes.tile(client.getTopLevelWorldView(), at.getX(), at.getY()); - List items = tile == null ? null : tile.getGroundItems(); - for (TileItem item : items == null ? List.of() : items) { - if (item.getId() != id) continue; + if (tile == null || tile.getGroundItems() == null) throw noGroundItem(id, at); - String name = Ops.nullToEmpty(client.getItemDefinition(id).getName()); + if (tile.getGroundItems().stream().noneMatch(item -> item.getId() == id)) throw noGroundItem(id, at); - return new Found(new GroundItemTarget(id, name, at, option), withExamine(Scenes.GROUND_ITEM_OPTIONS)); - } + String name = Ops.nullToEmpty(client.getItemDefinition(id).getName()); + + return new Candidate(new GroundItemTarget(id, name, at, option), withExamine(Scenes.GROUND_ITEM_OPTIONS)); + } - throw notFound("no ground item " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); + /** Build the failure for a ground item that is not on the world tile. */ + private static BridgeException noGroundItem(int id, WorldPoint at) { + return notFound("no ground item " + id + " is on tile " + Scenes.words(at.getX(), at.getY())); } /** Get the options offered among the five of a target, followed by the examine option that each such target has. */ diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java index 46f06277..e1ab62c6 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/WidgetTarget.java @@ -19,18 +19,18 @@ final class WidgetTarget extends Target { /** The menu actions that perform an option on an interface component. */ private static final Set FAMILY = EnumSet.of( - MenuAction.CC_OP, - MenuAction.CC_OP_LOW_PRIORITY, - MenuAction.WIDGET_CONTINUE, - MenuAction.WIDGET_TYPE_1, - MenuAction.WIDGET_TYPE_4, - MenuAction.WIDGET_TYPE_5, - MenuAction.WIDGET_CLOSE, - MenuAction.WIDGET_FIRST_OPTION, - MenuAction.WIDGET_SECOND_OPTION, - MenuAction.WIDGET_THIRD_OPTION, - MenuAction.WIDGET_FOURTH_OPTION, - MenuAction.WIDGET_FIFTH_OPTION); + MenuAction.CC_OP, + MenuAction.CC_OP_LOW_PRIORITY, + MenuAction.WIDGET_CONTINUE, + MenuAction.WIDGET_TYPE_1, + MenuAction.WIDGET_TYPE_4, + MenuAction.WIDGET_TYPE_5, + MenuAction.WIDGET_CLOSE, + MenuAction.WIDGET_FIRST_OPTION, + MenuAction.WIDGET_SECOND_OPTION, + MenuAction.WIDGET_THIRD_OPTION, + MenuAction.WIDGET_FOURTH_OPTION, + MenuAction.WIDGET_FIFTH_OPTION); /** The reference as the caller wrote it, or as the dialog was resolved to. */ private final String ref; From 2a73f8f16ff760cf08f91cc6c3dd79691c0b3978 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 21:10:13 +0200 Subject: [PATCH 17/72] docs(mcp): rewrap the paragraph on what the tests cover Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 6e7f83f7..8b981ad7 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -258,11 +258,11 @@ log. The client comes back on new ports, and new packets are marked with the nex ## Check it against a live client The automated tests cover the packet log, the endpoint's HTTP and JSON-RPC handling, the tool calls -as an MCP client makes them, the session failure paths and the bridge handshake. They do not run a game client, so what the client does is -checked by hand. Run this after a RuneLite update, a game revision change, or a change to the plugin. -Each step names what proves it, so a step that fails names what changed. A tool that reports a -dropped action, or lists what the client offered instead, is working as intended. Read its message -before suspecting the tool. +as an MCP client makes them, the session failure paths and the bridge handshake. They do not run a +game client, so what the client does is checked by hand. Run this after a RuneLite update, a game +revision change, or a change to the plugin. Each step names what proves it, so a step that fails +names what changed. A tool that reports a dropped action, or lists what the client offered instead, +is working as intended. Read its message before suspecting the tool. Start the server and a session against a target you can log in to, with a new account: From 72d8b449b518524709b2e41c90a6568270dcb318 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Sat, 3 Oct 2026 22:36:11 +0200 Subject: [PATCH 18/72] fix(mcp): turn the camera when an interface covers the target A tile drawn under the chatbox failed with "an open interface is blocking the game view", and the loop gave up. The client offers only Cancel at a point that an interface covers, so the loop never clicked there, but it kept aiming at the same covered spot until the deadline. After three such aims in a row the loop now turns the camera toward the target, which moves it to another part of the screen, and the message says that an interface covers the game view at that point. Found while running two clients at once, where one window was small enough for the chatbox to cover the tile. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 18 ++++++--- .../net/rsprox/mcpbridge/Interaction.java | 37 +++++++++++++++++-- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 8b981ad7..c84465cf 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -183,12 +183,18 @@ number or its text. Both are proven by `RESUME_PAUSEBUTTON`. A button of an inte `IF_BUTTON` packet, and a close button by `CLOSE_MODAL`. When the client never offers the option within the deadline, about three ticks, the call fails with -`wrong_state` and says what the client offered at the last attempt instead, such as -`the client offered [Walk here, Examine Tree]`. An interface that blocks the game view, such as the -bank, gives `the client offers only Cancel; an open interface is blocking the game view`; close it -first. A target that is hidden from every angle, behind a wall for instance, cannot be clicked, and -neither can anything in a secondary world view such as a boat: both tools read only the top-level -world view, on the plane of the local player. +`wrong_state` and says what the client offered at the last attempt instead, such as `the client +offered [Walk here, Examine Tree]`. + +The client offers only Cancel at a point that an interface covers, such as the chatbox, the minimap +or the inventory. The tool never clicks there. After a few such points it turns the camera, which +moves the target to another part of the screen. If that does not help, the call fails with `the +client offers only Cancel; an interface covers the game view there`. An interface that blocks the +whole game view, such as the bank, gives the same message; close it first. + +A target that is hidden from every angle, behind a wall for instance, cannot be clicked, and neither +can anything in a secondary world view such as a boat: both tools read only the top-level world +view, on the plane of the local player. `client_camera` turns the camera on purpose: pass `yaw` and `pitch` in the client's units, or `look_at` with the target fields of `client_interact`, and it returns the settled `yaw` and `pitch` diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java index 7987a06a..217de0c5 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java @@ -48,8 +48,11 @@ final class Interaction { /** The number of last attempts whose words are reported. */ private static final int KEPT = 5; - /** The message when an interface blocks the game view. */ - private static final String BLOCKED = "the client offers only Cancel; an open interface is blocking the game view"; + /** The message when the client offers nothing at the aim, which an interface over that point causes. */ + private static final String BLOCKED = "the client offers only Cancel; an interface covers the game view there"; + + /** The number of covered aims in a row after which the camera is turned to move the target clear. */ + private static final int COVERED_BEFORE_TURN = 3; /** The words added to an attempt whose left click the client turned into another entry. */ private static final String MENU_FROM_NOW = "; a left click performs another option here, so the menu is used next"; @@ -90,6 +93,9 @@ final class Interaction { /** Whether the client has shown that a left click on the target performs another entry than the intended one. */ private boolean anotherPreferred; + /** The number of aims in a row at which the client offered only Cancel. */ + private int covered; + /** The canvas point of the press that the guard let through, or null until it let one through. */ private Point pressed; @@ -160,8 +166,12 @@ private Verdict attempt(Point aim) throws BridgeException { String at = " at " + Scenes.words(aim.x, aim.y); + if (offer.match < 0 && offer.onlyCancel) return covered(offer, at); + if (offer.match < 0) return missed(offer, offerWords(offer) + at); + covered = 0; + if (!offer.underMouse) return missed(offer, target.label() + " moved out from under the mouse" + at); if (offer.isLast() && !offer.open && !anotherPreferred) return leftClick(aim, offer.offered.get(offer.match)); @@ -291,7 +301,28 @@ private static String offerWords(Offer offer) { return offer.onlyCancel ? BLOCKED : "the client offered " + offer.words(); } - /** Turn the camera toward the target, which is not on screen, and extend the deadline for the turn. */ + /** + * Miss an aim at which the client offers only Cancel. After several such aims in a row, turn the + * camera toward a world target, since an interface such as the chatbox covers where it is drawn. + */ + private Verdict covered(Offer offer, String at) throws BridgeException { + Verdict verdict = missed(offer, BLOCKED + at); + covered++; + + if (covered < COVERED_BEFORE_TURN) return verdict; + + WorldPoint tile = game.onClientThread(target::tile); + if (tile == null) return verdict; + + deadline = Math.max(deadline, began + CAMERA_CYCLES); + + return Verdict.missed(BLOCKED + at + "; " + game.onClientThread(client -> camera.toward(client, tile))); + } + + /** + * Turn the camera toward a target that has no clickable shape on screen, and allow the longer + * deadline that turning needs. + */ private Verdict bringIntoView() throws BridgeException { WorldPoint tile = game.onClientThread(target::tile); String words = "no part of " + target.label() + " is on screen"; From 4fd935e455792cca4907dbe721a5476f5bc5741a Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 10:15:27 +0200 Subject: [PATCH 19/72] feat(proxy): let listeners observe the clients the proxy launches The proxy holds one session monitor per proxy port, and it belongs to whoever launched the client. Nothing else could see the packets of a client that the GUI launched. A ClientListener is told of every client that is launched with a session monitor, and may return a monitor of its own. The proxy then fans the callbacks out: the launcher's monitor first, with its exceptions passed on as before, and then each observer, whose exceptions are logged once and dropped. With no listener the launcher's monitor is used as it is. The listener also hears when the client is gone: when its port is killed, when the launch fails, and when every process of the client that was alive after the launch has exited. A RuneLite launcher that hands its client over and exits before that point leaves no process to watch, so the exit of such a client is not seen. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/proxy/ClientListener.kt | 23 +++ .../kotlin/net/rsprox/proxy/ProxyService.kt | 110 +++++++++++-- .../rsprox/proxy/util/FanOutSessionMonitor.kt | 94 +++++++++++ .../net/rsprox/proxy/ClientListenerTest.kt | 90 ++++++++++ .../proxy/util/FanOutSessionMonitorTest.kt | 155 ++++++++++++++++++ 5 files changed, 454 insertions(+), 18 deletions(-) create mode 100644 proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt create mode 100644 proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt create mode 100644 proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt create mode 100644 proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt new file mode 100644 index 00000000..7363e926 --- /dev/null +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt @@ -0,0 +1,23 @@ +package net.rsprox.proxy + +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor + +/** Hears of every client that is launched with a session monitor, whoever launched it. */ +public interface ClientListener { + /** + * Called before the client on [port] is started. A monitor that is returned hears every callback + * of that client, after the monitor of whoever launched it. + */ + public fun onClientLaunch( + port: Int, + target: ProxyTargetConfig, + ): SessionMonitor? + + /** + * Called when the client on [port] was killed, failed to launch or exited. It may be called more + * than once for a client, and for a port that no client was launched on. + */ + public fun onClientClosed(port: Int) +} diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt index aed66840..e607d5d8 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt @@ -103,7 +103,9 @@ import java.nio.file.Path import java.time.Instant import java.util.* import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.ForkJoinPool import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -133,6 +135,7 @@ public class ProxyService( private var initialPort: Int = -1 private val processes = ConcurrentHashMap>() private val connections: ProxyConnectionContainer = ProxyConnectionContainer() + private val clientListeners = CopyOnWriteArrayList() private lateinit var credentials: BinaryCredentialsStore private var rspsModulus: String? = null public lateinit var proxyTargets: List @@ -597,6 +600,7 @@ public class ProxyService( public fun killAliveProcess(port: Int) { removeSessionMonitor(port) + notifyClientClosed(port) val processList = processes.remove(port) ?: return for (process in processList) { try { @@ -724,14 +728,62 @@ public class ProxyService( logger.error(t) { "Unable to bind network port $port for native client." } return } - this.connections.addSessionMonitor(port, sessionMonitor) - ClientTypeDictionary[port] = "RuneLite (${operatingSystem.shortName})" - launchJavaProcess( - port, - operatingSystem, - character, - target, - ) + launchObserved(port) { + this.connections.addSessionMonitor(port, observed(sessionMonitor, port, target)) + ClientTypeDictionary[port] = "RuneLite (${operatingSystem.shortName})" + launchJavaProcess( + port, + operatingSystem, + character, + target, + ) + } + } + + public fun addClientListener(listener: ClientListener) { + clientListeners += listener + } + + private fun observed( + sessionMonitor: SessionMonitor, + port: Int, + target: ProxyTarget, + ): SessionMonitor { + val observers = + clientListeners.mapNotNull { listener -> + try { + listener.onClientLaunch(port, target.config) + } catch (t: Throwable) { + logger.error(t) { "Client listener failed for the launch on port $port" } + null + } + } + if (observers.isEmpty()) return sessionMonitor + return FanOutSessionMonitor(sessionMonitor, observers) + } + + private inline fun launchObserved( + port: Int, + launch: () -> Unit, + ) { + try { + launch() + } catch (t: Throwable) { + notifyClientClosed(port) + throw t + } + // A launcher that handed its client over and exited before this point leaves nothing to watch. + whenAllExit(processes[port].orEmpty()) { notifyClientClosed(port) } + } + + private fun notifyClientClosed(port: Int) { + for (listener in clientListeners) { + try { + listener.onClientClosed(port) + } catch (t: Throwable) { + logger.error(t) { "Client listener failed for the close on port $port" } + } + } } public fun loadReplaySession(path: Path): ReplaySession = @@ -950,16 +1002,18 @@ public class ProxyService( logger.error(t) { "Unable to bind network port $port for native client." } return } - launchNativeClientProcess( - os = os, - rsa = rsa, - character = character, - port = port, - target = target, - clientTypeLabel = "Native (${os.shortName})", - registerConnectionInfo = true, - sessionMonitor = sessionMonitor, - ) + launchObserved(port) { + launchNativeClientProcess( + os = os, + rsa = rsa, + character = character, + port = port, + target = target, + clientTypeLabel = "Native (${os.shortName})", + registerConnectionInfo = true, + sessionMonitor = observed(sessionMonitor, port, target), + ) + } } public fun launchRs3Client( @@ -1648,3 +1702,23 @@ public class ProxyService( } } } + +/** + * Run [action] once every process of [processes] that is alive now, and every descendant of it, has exited. + * Returns false, and never runs [action], when none of them is alive. + */ +internal fun whenAllExit( + processes: List, + action: () -> Unit, +): Boolean { + val alive = + processes + .flatMap { process -> process.descendants().collect(Collectors.toList()) + process } + .distinct() + .filter { it.isAlive } + if (alive.isEmpty()) return false + CompletableFuture + .allOf(*alive.map { it.onExit() }.toTypedArray()) + .thenRun(action) + return true +} diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt b/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt new file mode 100644 index 00000000..b38463b1 --- /dev/null +++ b/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt @@ -0,0 +1,94 @@ +package net.rsprox.proxy.util + +import com.github.michaelbull.logging.InlineLogger +import net.rsprox.cache.api.CacheProvider +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.RootProperty +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Passes every callback to [primary] and then to each of [observers]. + * [primary] behaves as if it were the only monitor: what it throws is passed on. An observer that + * throws is logged once and otherwise ignored, so it can never keep a callback from another monitor. + */ +public class FanOutSessionMonitor( + private val primary: SessionMonitor, + private val observers: List>, +) : SessionMonitor { + private val failureLogged = AtomicBoolean() + + override fun forSession(header: T): SessionMonitor { + val primary = this.primary.forSession(header) + val observers = this.observers.mapNotNull { observer -> guarded { observer.forSession(header) } } + if (observers.isEmpty()) return primary + return FanOutSessionMonitor(primary, observers) + } + + override fun onPacketDirection(direction: StreamDirection) { + each { it.onPacketDirection(direction) } + } + + override fun onLogin(header: T) { + each { it.onLogin(header) } + } + + override fun onLogout(header: T) { + each { it.onLogout(header) } + } + + override fun onCacheUpdate(cacheProvider: CacheProvider) { + each { it.onCacheUpdate(cacheProvider) } + } + + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + each { it.onIncomingBytesPerSecondUpdate(bytesPerLastSecond) } + } + + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { + each { it.onOutgoingBytesPerSecondUpdate(bytesPerLastSecond) } + } + + override fun onNameUpdate(name: String) { + each { it.onNameUpdate(name) } + } + + override fun onUserInformationUpdate( + userId: Long, + userHash: Long, + ) { + each { it.onUserInformationUpdate(userId, userHash) } + } + + override fun onTranscribe( + cycle: Int, + property: RootProperty, + ) { + each { it.onTranscribe(cycle, property) } + } + + private inline fun each(call: (SessionMonitor) -> Unit) { + try { + call(primary) + } finally { + for (observer in observers) { + guarded { call(observer) } + } + } + } + + private inline fun guarded(call: () -> R): R? { + return try { + call() + } catch (t: Throwable) { + if (failureLogged.compareAndSet(false, true)) { + logger.error(t) { "A session observer failed. Its further failures are not logged." } + } + null + } + } + + private companion object { + private val logger = InlineLogger() + } +} diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt new file mode 100644 index 00000000..f67a7b47 --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt @@ -0,0 +1,90 @@ +package net.rsprox.proxy + +import io.netty.buffer.ByteBufAllocator +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Runs until its input ends, so a test decides when it exits. */ +object WaitsForInput { + @JvmStatic + fun main(args: Array) { + System.`in`.read() + } +} + +class ClientListenerTest { + private class ClosedPorts( + private val failing: Boolean = false, + ) : ClientListener { + val closed = ArrayList() + + override fun onClientLaunch( + port: Int, + target: ProxyTargetConfig, + ): SessionMonitor? = null + + override fun onClientClosed(port: Int) { + closed += port + if (failing) throw IllegalStateException("cannot close") + } + } + + private fun waiting(): Process { + val java = ProcessHandle.current().info().command().get() + + return ProcessBuilder(java, "-cp", System.getProperty("java.class.path"), WaitsForInput::class.java.name).start() + } + + @Test + fun `killing the client on a port tells every listener that it closed, past one that throws`() { + val service = ProxyService(ByteBufAllocator.DEFAULT) + val failing = ClosedPorts(failing = true) + val later = ClosedPorts() + service.addClientListener(failing) + service.addClientListener(later) + + service.killAliveProcess(43701) + + assertEquals(listOf(43701), failing.closed) + assertEquals(listOf(43701), later.closed) + } + + @Test + fun `the exit of a process is reported once the last of the watched processes has exited`() { + val first = waiting() + val second = waiting() + val exited = CompletableFuture() + + try { + assertTrue(whenAllExit(listOf(first.toHandle(), second.toHandle())) { exited.complete(Unit) }) + + first.outputStream.close() + first.waitFor(20, TimeUnit.SECONDS) + assertFailsWith { exited.get(300, TimeUnit.MILLISECONDS) } + + second.outputStream.close() + exited.get(20, TimeUnit.SECONDS) + } finally { + first.destroyForcibly() + second.destroyForcibly() + } + } + + @Test + fun `processes that have all exited are not watched`() { + val process = waiting() + process.outputStream.close() + process.waitFor(20, TimeUnit.SECONDS) + + assertFalse(whenAllExit(listOf(process.toHandle())) { error("nothing is left to exit") }) + } +} diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt new file mode 100644 index 00000000..662c9e8e --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt @@ -0,0 +1,155 @@ +package net.rsprox.proxy.util + +import net.rsprox.cache.api.CacheProvider +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.ChildProperty +import net.rsprox.shared.property.RootProperty +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class FanOutSessionMonitorTest { + private class Recorder( + private val label: String = "", + private val failing: Boolean = false, + ) : SessionMonitor { + val calls = ArrayList() + val logins = ArrayList() + + private fun record(call: String) { + calls += label + call + if (failing) throw IllegalStateException("$label$call failed") + } + + override fun forSession(header: String): SessionMonitor { + record("forSession $header") + + return Recorder("$header:").also { logins += it } + } + + override fun onPacketDirection(direction: StreamDirection) = record("direction $direction") + + override fun onLogin(header: String) = record("login $header") + + override fun onLogout(header: String) = record("logout $header") + + override fun onCacheUpdate(cacheProvider: CacheProvider) = record("cache") + + override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) = record("in $bytesPerLastSecond") + + override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) = record("out $bytesPerLastSecond") + + override fun onNameUpdate(name: String) = record("name $name") + + override fun onUserInformationUpdate( + userId: Long, + userHash: Long, + ) = record("user $userId $userHash") + + override fun onTranscribe( + cycle: Int, + property: RootProperty, + ) = record("transcribe $cycle ${property.prot}") + } + + private val packet = + object : RootProperty { + override val prot = "IF_SETTEXT" + override val children = mutableListOf>() + } + + private fun everyCallback(monitor: SessionMonitor) { + monitor.onLogin("a") + monitor.onUserInformationUpdate(1, 2) + monitor.onCacheUpdate { error("not read") } + monitor.onPacketDirection(StreamDirection.CLIENT_TO_SERVER) + monitor.onTranscribe(7, packet) + monitor.onNameUpdate("Alice") + monitor.onIncomingBytesPerSecondUpdate(3) + monitor.onOutgoingBytesPerSecondUpdate(4) + monitor.onLogout("a") + } + + private val everyCall = + listOf( + "login a", + "user 1 2", + "cache", + "direction CLIENT_TO_SERVER", + "transcribe 7 IF_SETTEXT", + "name Alice", + "in 3", + "out 4", + "logout a", + ) + + @Test + fun `every callback reaches the primary monitor and each observer`() { + val primary = Recorder() + val first = Recorder() + val second = Recorder() + + everyCallback(FanOutSessionMonitor(primary, listOf(first, second))) + + assertEquals(everyCall, primary.calls) + assertEquals(everyCall, first.calls) + assertEquals(everyCall, second.calls) + } + + @Test + fun `the callbacks of a login reach the login monitor of the primary and of each observer`() { + val primary = Recorder() + val observer = Recorder() + + everyCallback(FanOutSessionMonitor(primary, listOf(observer)).forSession("a")) + + assertEquals(listOf("forSession a"), primary.calls) + assertEquals(listOf("forSession a"), observer.calls) + assertEquals(everyCall.map { "a:$it" }, primary.logins.single().calls) + assertEquals(everyCall.map { "a:$it" }, observer.logins.single().calls) + } + + @Test + fun `an observer that throws keeps no callback from the primary monitor or from a later observer`() { + val primary = Recorder() + val later = Recorder() + + everyCallback(FanOutSessionMonitor(primary, listOf(Recorder(failing = true), later))) + + assertEquals(everyCall, primary.calls) + assertEquals(everyCall, later.calls) + } + + @Test + fun `an observer that fails to make its login monitor is left out of that login`() { + val primary = Recorder() + val later = Recorder() + + everyCallback(FanOutSessionMonitor(primary, listOf(Recorder(failing = true), later)).forSession("a")) + + assertEquals(everyCall.map { "a:$it" }, primary.logins.single().calls) + assertEquals(everyCall.map { "a:$it" }, later.logins.single().calls) + } + + @Test + fun `a login without a working observer is monitored by the login monitor of the primary alone`() { + val primary = Recorder() + + val login = FanOutSessionMonitor(primary, listOf(Recorder(failing = true))).forSession("a") + + assertSame(primary.logins.single(), login) + } + + @Test + fun `what the primary monitor throws is passed on after the observers heard the callback`() { + val observer = Recorder() + val monitor = FanOutSessionMonitor(Recorder(failing = true), listOf(observer)) + + val thrown = assertFailsWith { monitor.onNameUpdate("Alice") } + + assertEquals("name Alice failed", thrown.message) + assertEquals(listOf("name Alice"), observer.calls) + } +} From 48738472f70c488b381cf9d6db9e13d2ed7de4a3 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 10:15:27 +0200 Subject: [PATCH 20/72] feat(proxy): add the MCP endpoint properties and proxy extensions The MCP endpoint is about to run inside the GUI. Two properties decide whether it is on and which loopback port it binds: mcp.enabled, on by default, and mcp.port, 43580 by default. The default port moves into the proxy so that the GUI and the standalone MCP server share one value. A ProxyExtension is a part of rsprox that lives in another module and is started inside the GUI's process. The GUI finds extensions through ServiceLoader, so it needs no dependency on the module that holds one. An extension that fails to start is logged and skipped. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/proxy/ProxyExtension.kt | 33 +++++++++++++++++++ .../kotlin/net/rsprox/proxy/ProxyService.kt | 10 ++++++ .../rsprox/proxy/config/ProxyProperties.kt | 5 +++ .../net/rsprox/proxy/config/ProxyProperty.kt | 7 ++++ .../net/rsprox/proxy/ProxyExtensionTest.kt | 31 +++++++++++++++++ .../proxy/config/ProxyPropertiesTest.kt | 29 ++++++++++++++++ .../services/net.rsprox.proxy.ProxyExtension | 2 ++ 7 files changed, 117 insertions(+) create mode 100644 proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt create mode 100644 proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt create mode 100644 proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt create mode 100644 proxy/src/test/resources/META-INF/services/net.rsprox.proxy.ProxyExtension diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt new file mode 100644 index 00000000..ef1889a3 --- /dev/null +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt @@ -0,0 +1,33 @@ +package net.rsprox.proxy + +import com.github.michaelbull.logging.InlineLogger +import java.util.ServiceConfigurationError +import java.util.ServiceLoader + +/** + * A part of rsprox that lives in another module and runs inside the process of the GUI. + * The GUI finds it through [ServiceLoader], so it does not depend on that module. + */ +public interface ProxyExtension { + /** Called once, after [service] has started. */ + public fun start(service: ProxyService) + + public companion object { + private val logger = InlineLogger() + + /** Start every extension on the classpath. One that fails is logged and skipped. */ + public fun startAll(service: ProxyService) { + try { + for (extension in ServiceLoader.load(ProxyExtension::class.java)) { + try { + extension.start(service) + } catch (t: Throwable) { + logger.error(t) { "Unable to start ${extension.javaClass.name}" } + } + } + } catch (e: ServiceConfigurationError) { + logger.error(e) { "Unable to load the proxy extensions" } + } + } + } +} diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt index e607d5d8..ed564f7f 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt @@ -34,6 +34,8 @@ import net.rsprox.proxy.config.ProxyProperty.Companion.BINARY_WRITE_INTERVAL_SEC import net.rsprox.proxy.config.ProxyProperty.Companion.BIND_TIMEOUT_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.FILTERS_STATUS import net.rsprox.proxy.config.ProxyProperty.Companion.JAV_CONFIG_ENDPOINT +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_ENABLED +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PORT import net.rsprox.proxy.config.ProxyProperty.Companion.PROXY_PORT_MIN import net.rsprox.proxy.config.ProxyProperty.Companion.RUNELITE_RSPROX_CONNECTION import net.rsprox.proxy.config.ProxyProperty.Companion.SELECTED_CLIENT @@ -504,6 +506,14 @@ public class ProxyService( return properties.getPropertyOrNull(FILTERS_STATUS) ?: 0 } + public fun getMcpEnabled(): Boolean { + return properties.getProperty(MCP_ENABLED) + } + + public fun getMcpPort(): Int { + return properties.getProperty(MCP_PORT) + } + public fun setSelectedClient(index: Int) { properties.setProperty(SELECTED_CLIENT, index) properties.saveProperties(PROPERTIES_FILE) diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt index 32e7c953..d295fbf9 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt @@ -6,6 +6,8 @@ import net.rsprox.proxy.config.ProxyProperty.Companion.APP_WIDTH import net.rsprox.proxy.config.ProxyProperty.Companion.BINARY_WRITE_INTERVAL_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.BIND_TIMEOUT_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.JAV_CONFIG_ENDPOINT +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_ENABLED +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PORT import net.rsprox.proxy.config.ProxyProperty.Companion.PROXY_PORT_MIN import net.rsprox.proxy.config.ProxyProperty.Companion.RUNELITE_RSPROX_CONNECTION import net.rsprox.proxy.config.ProxyProperty.Companion.WORLDLIST_ENDPOINT @@ -93,6 +95,9 @@ public value class ProxyProperties private constructor( properties.setValue(APP_THEME, "RuneLite") properties.setValue(APP_WIDTH, 800) properties.setValue(APP_HEIGHT, 600) + // mcp + properties.setValue(MCP_ENABLED, true) + properties.setValue(MCP_PORT, DEFAULT_MCP_PORT) // other properties.setValue(RUNELITE_RSPROX_CONNECTION, false) return properties diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt index 1b6a6779..4b2e6c7f 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt @@ -25,7 +25,14 @@ public class ProxyProperty( val SELECTED_CLIENT = ProxyProperty("app.client", IntProperty) val SELECTED_PROXY_TARGET = ProxyProperty("app.target", IntProperty) + // mcp + val MCP_ENABLED = ProxyProperty("mcp.enabled", BooleanProperty) + val MCP_PORT = ProxyProperty("mcp.port", IntProperty) + // other val RUNELITE_RSPROX_CONNECTION = ProxyProperty("runelite.rsprox.connection", BooleanProperty) } } + +/** The loopback port of the MCP endpoint, for the GUI and for the standalone MCP server alike. */ +public const val DEFAULT_MCP_PORT: Int = 43580 diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt new file mode 100644 index 00000000..050fe2b0 --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt @@ -0,0 +1,31 @@ +package net.rsprox.proxy + +import io.netty.buffer.ByteBufAllocator +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Extensions that the test registers through `META-INF/services`. */ +class FailingExtension : ProxyExtension { + override fun start(service: ProxyService): Unit = throw IllegalStateException("cannot start") +} + +class RecordingExtension : ProxyExtension { + override fun start(service: ProxyService) { + started += service + } + + companion object { + val started = ArrayList() + } +} + +class ProxyExtensionTest { + @Test + fun `an extension that fails to start does not keep the next one from starting`() { + val service = ProxyService(ByteBufAllocator.DEFAULT) + + ProxyExtension.startAll(service) + + assertEquals(listOf(service), RecordingExtension.started) + } +} diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt new file mode 100644 index 00000000..2c0327c3 --- /dev/null +++ b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt @@ -0,0 +1,29 @@ +package net.rsprox.proxy.config + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals + +class ProxyPropertiesTest { + @Test + fun `a properties file from before the MCP endpoint reads it as on, at the default port`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "app.theme=RuneLite\n") + + val properties = ProxyProperties(file) + + assertEquals(true, properties.getProperty(ProxyProperty.MCP_ENABLED)) + assertEquals(43580, properties.getProperty(ProxyProperty.MCP_PORT)) + } + + @Test + fun `the MCP endpoint can be turned off and moved to another port`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "mcp.enabled=false\nmcp.port=43999\n") + + val properties = ProxyProperties(file) + + assertEquals(false, properties.getProperty(ProxyProperty.MCP_ENABLED)) + assertEquals(43999, properties.getProperty(ProxyProperty.MCP_PORT)) + } +} diff --git a/proxy/src/test/resources/META-INF/services/net.rsprox.proxy.ProxyExtension b/proxy/src/test/resources/META-INF/services/net.rsprox.proxy.ProxyExtension new file mode 100644 index 00000000..a6bfe43c --- /dev/null +++ b/proxy/src/test/resources/META-INF/services/net.rsprox.proxy.ProxyExtension @@ -0,0 +1,2 @@ +net.rsprox.proxy.FailingExtension +net.rsprox.proxy.RecordingExtension From 9a4059c1a2d1598904cfd299d6814011de1d3ceb Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 10:24:23 +0200 Subject: [PATCH 21/72] feat(mcp): attach sessions to clients that something else launched A session was always a client that the MCP server launched and owned. An agent could not read the packets of a client that the owner of the proxy started by hand. A session is now one of two types. A launched session is what a session was: the server starts its client, drives it and may stop it. An attached session belongs to a client that something else launched. It has the same packet log, cursors and login numbers, so packets_read works on it unchanged, but it has no link to the client and no process to kill. session_stop, session_start and every client_* tool refuse it by asking the session for what they need, so no tool checks a flag. When its client is gone the session ends and stays listed, as a stopped launched session does. Launches and attaches share the s1, s2 numbering through a lock of their own. A launch holds the launch lock for as long as its launcher takes, and the proxy reports that same launch back on another thread, so numbering under the launch lock would block that thread. session_list now tells sessions apart. Each has its kind, and a login reports the world host, the index of the local player, the time of the login, the capture file, whether it is online and the newest tick, each from the moment the proxy knows it. A value that is not known yet is left out, which also drops the null name of a login that has none yet. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/packets/PacketTap.kt | 26 +- .../kotlin/net/rsprox/mcp/server/Tools.kt | 39 ++- .../kotlin/net/rsprox/mcp/session/Session.kt | 201 +++++++++--- .../net/rsprox/mcp/session/SessionManager.kt | 125 +++++-- .../rsprox/mcp/server/AttachedSessionTest.kt | 305 ++++++++++++++++++ .../mcp/session/SessionManagerRecoveryTest.kt | 2 +- 6 files changed, 616 insertions(+), 82 deletions(-) create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt index 53f28677..89bb2922 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -1,6 +1,7 @@ package net.rsprox.mcp.packets import net.rsprox.cache.api.CacheProvider +import net.rsprox.mcp.session.LoginInfo import net.rsprox.mcp.session.LoginRegistry import net.rsprox.proxy.binary.BinaryHeader import net.rsprox.shared.SessionMonitor @@ -13,7 +14,7 @@ import net.rsprox.shared.settings.SettingSetStore import net.rsprox.shared.symbols.SymbolDictionaryProvider /** - * The session monitor of one launch. The proxy only delivers callbacks to the per-login instance that + * The session monitor of one client. The proxy only delivers callbacks to the per-login instance that * [forSession] returns, so the callbacks of this class itself do nothing. */ internal class PacketTap( @@ -23,10 +24,12 @@ internal class PacketTap( private val logins: LoginRegistry, /** The settings that the packets are formatted with. */ private val settings: SettingSetStore, + /** The folder the proxy records the logins to, under its `binary` directory, or null when it records none. */ + private val captureFolder: String?, ) : SessionMonitor { /** Create the tap of one login, under the next login epoch. */ override fun forSession(header: BinaryHeader): SessionMonitor = - LoginTap(log, logins, settings, logins.nextEpoch()) + LoginTap(log, logins, settings, logins.nextEpoch(), captureFolder?.let { "$it/${header.fileName()}" }) /** Ignore the login, which the proxy reports to the tap of that login. */ override fun onLogin(header: BinaryHeader) { @@ -84,6 +87,8 @@ private class LoginTap( settings: SettingSetStore, /** The login epoch that every record of this tap carries. */ private val epoch: Int, + /** The file the proxy records this login to, or null when it records none. */ + private val captureFile: String?, ) : SessionMonitor { /** * The game cache that the formatter looks names up in, or null before the proxy has handed it over. @@ -98,6 +103,9 @@ private class LoginTap( /** Whether a packet of this login has been decoded. Only touched on the transcriber worker. */ private var transcribing = false + /** The newest tick that was registered for this login. Only touched on the transcriber worker. */ + private var tick = -1 + /** The formatter that turns a decoded packet into text. */ private val formatter: PropertyTreeFormatter = OmitFilteredPropertyTreeFormatter( @@ -106,9 +114,12 @@ private class LoginTap( }, ) - /** Keep the cache for the formatter. */ + /** Keep the cache for the formatter, and register the file that the login is recorded to. */ override fun onCacheUpdate(cacheProvider: CacheProvider) { cache = cacheProvider + + // The proxy hands the cache over right after it has set the recording of the login up. + logins.update(epoch) { it.copy(captureFile = captureFile) } } /** Keep the direction of the next packet. The first packet also registers that this login is being transcribed. */ @@ -124,7 +135,7 @@ private class LoginTap( } /** - * Append the packet to the log as text. + * Append the packet to the log as text, and register its tick when it is a new one. * Formats on the worker, while the transcriber's session state still matches the packet. */ override fun onTranscribe( @@ -141,11 +152,16 @@ private class LoginTap( } log.append(epoch, cycle, origin, property.prot.uppercase(), text) + + if (cycle != tick) { + tick = cycle + logins.update(epoch) { it.copy(tick = cycle) } + } } /** Register the login and mark it in the log. */ override fun onLogin(header: BinaryHeader) { - logins.login(epoch, header.revision, header.worldId) + logins.login(LoginInfo.of(epoch, header)) log.append(epoch, 0, Origin.PROXY, "LOGIN", "revision=${header.revision} world=${header.worldId}") } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index f448003c..1ccf41c6 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -26,7 +26,7 @@ public fun tools(sessions: () -> SessionManager): List = "call it again with the same `session` to keep waiting. With no arguments it starts a new " + "session on the first custom target. Pass `session` to relaunch a stopped session on fresh " + "ports; its packet log and cursor continue. Calling it for a session that is already " + - "running launches nothing.", + "running launches nothing. An attached session cannot be started.", inputSchema = schema( "target" to string("Proxy target name, as listed by session_list. Example: \"My Server\"."), @@ -49,14 +49,27 @@ public fun tools(sessions: () -> SessionManager): List = }, Tool( name = "session_stop", - description = "Kill the client of a session. The session and its packets stay readable.", + description = + "Kill the client of a session that session_start launched. The session and its packets stay " + + "readable. Not available for an attached session, whose client belongs to whoever runs " + + "the rsprox GUI.", inputSchema = schema("session" to SESSION), ) { args -> ToolResult.Json(sessions().stop(args.text("session"))) }, Tool( name = "session_list", - description = "List every session with its state, ports, login and packet cursor, plus the target names.", + description = + "List every session, plus the target names. `kind` is `launched` for a client that " + + "session_start launched, and `attached` for a client that was launched by hand from the " + + "rsprox GUI, which only packets_read works on. Each session has `session`, `target`, " + + "`state`, `proxyPort` and `cursor`, the newest packet cursor. `state` is `stopped`, " + + "`launching` or `connected` when launched, and `attached` or `ended` when attached; " + + "`reason` says why a session has no client. Once the client has logged in, `login` holds " + + "`epoch` (the L number of its packets), `revision`, `world`, `host`, `localPlayerIndex`, " + + "`connectedAt`, `online` (whether it is still logged in) and `transcribing` (whether its " + + "packets are decoded). `captureFile`, the recording under the rsprox binary directory, " + + "`name`, the display name, and `tick`, the newest server tick seen, are absent until known.", inputSchema = schema(), ) { val manager = sessions() @@ -65,14 +78,16 @@ public fun tools(sessions: () -> SessionManager): List = Tool( name = "packets_read", description = - "Read decoded packets of a session after a cursor, unfiltered, in both directions. " + + "Read decoded packets of a session after a cursor, in both directions. The standalone MCP " + + "server logs every packet, unfiltered. Inside the rsprox GUI the log holds the GUI's view: " + + "a packet that the GUI's filters or settings hide is missing from every session. " + "To wait for a packet, pass `wait_ms`. The call then blocks until the first match. " + "Line 1 of the result is JSON: pass `next` as `after` on the following call; `dropped` counts " + "records that were evicted before they could be read; `timedOut` is true when a wait " + "elapsed with no match. Each further line is one packet: " + "` L T `, where C is client to server, S is server " + - "to client and P is an rsprox marker (CLIENT_LAUNCHED, CLIENT_CONNECTED, CLIENT_EXITED, " + - "LOGIN, LOGOUT). Continuation lines of one packet are indented.", + "to client and P is an rsprox marker (CLIENT_LAUNCHED, CLIENT_CONNECTED, CLIENT_ATTACHED, " + + "CLIENT_EXITED, LOGIN, LOGOUT). Continuation lines of one packet are indented.", inputSchema = schema( "session" to SESSION, @@ -307,6 +322,7 @@ public fun tools(sessions: () -> SessionManager): List = * Build a client_* tool, which forwards its arguments to the op of the same name in the plugin. * The plugin's answer is the tool result, plus the packet cursor * taken before the call, so everything the action caused has a sequence number above it. + * The session decides whether the tool may reach its client, and refuses it for an attached session. * * The plugin has no defaults of its own: each of [defaults] is forwarded when the caller left it out. */ @@ -319,8 +335,9 @@ private fun clientTool( timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, result: (ok: ObjectNode, session: Session) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, ): Tool = - Tool(name, "$description $CURSOR_NOTE", schema) { args -> + Tool(name, "$description $CLIENT_TOOL_NOTE", schema) { args -> val session = sessions().resolve(args.text("session")) + val link = session.link(name) val cursor = session.packets.head().seq val forwarded = args.deepCopy().without("session") @@ -328,7 +345,7 @@ private fun clientTool( if (!forwarded.hasNonNull(argument)) forwarded.put(argument, value) } - val ok = session.requireLink().call(name.removePrefix("client_"), forwarded, timeoutMs(forwarded)) + val ok = link.call(name.removePrefix("client_"), forwarded, timeoutMs(forwarded)) if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") result(ok.put("cursor", cursor), session) @@ -361,10 +378,10 @@ private const val ENTITY_LIMIT = 100L */ private const val SENT_WAIT_MS = 3_000L -/** The sentence that ends the description of every client_* tool. */ -private const val CURSOR_NOTE = +/** The sentences that end the description of every client_* tool. */ +private const val CLIENT_TOOL_NOTE = "The result carries `cursor`, the packet cursor taken just before the call: pass it as `after` to " + - "packets_read to see only the packets from this call onwards." + "packets_read to see only the packets from this call onwards. Not available for an attached session." /** The schema of the `session` argument that every tool but session_start and session_list takes. */ private val SESSION: ObjectNode = string("Session id, such as \"s1\". May be omitted while only one session exists.") diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 509bbf97..7f7fe2c4 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -5,7 +5,9 @@ import net.rsprox.mcp.bridge.BridgeLink import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.PacketLog import net.rsprox.mcp.server.ToolError +import net.rsprox.proxy.binary.BinaryHeader import net.rsprox.proxy.target.ProxyTargetConfig +import java.time.Instant import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock @@ -30,7 +32,7 @@ public data class Launch( val httpPort: Int, ) -/** Whether a session has a client, and how far that client has come. */ +/** Whether a launched session has a client, and how far that client has come. */ public sealed interface ClientState { /** The session has no client. */ public data class Stopped( @@ -55,7 +57,15 @@ public sealed interface ClientState { ) : ClientState } -/** Everything that can change a session's [ClientState]. Several threads raise these; [reduce] decides. */ +/** Get the proxy port of the client, or null when the state has no client. */ +internal fun ClientState.proxyPort(): Int? = + when (this) { + is ClientState.Stopped -> null + is ClientState.Launching -> launch.proxyPort + is ClientState.Connected -> launch.proxyPort + } + +/** What can change the [ClientState] of a launched session. Several threads raise these. [reduce] decides. */ public sealed interface SessionEvent { /** A launch of the client began. */ public data class Launched( @@ -124,7 +134,8 @@ private fun reduce( } } -/** One login of a session, as the proxy reports it. */ +/** One login of a session, as the proxy reports it. A value that is not known yet is left out of the JSON. */ +@JsonInclude(JsonInclude.Include.NON_NULL) public data class LoginInfo( /** The number of this login within its session, starting at 1. */ val epoch: Int, @@ -132,7 +143,18 @@ public data class LoginInfo( val revision: Int, /** The world that was logged in to. */ val world: Int, - /** The name of the player, or null until the proxy reports it. */ + /** The host of that world. */ + val host: String, + /** The index of the local player in that world. */ + val localPlayerIndex: Int, + /** The moment the server accepted the login, in ISO-8601. */ + val connectedAt: String, + /** + * The file the login is recorded to, relative to the `binary` directory of rsprox. + * Null for a target that is not recorded, and until the proxy has set the recording up. + */ + val captureFile: String?, + /** The display name of the player, or null until the proxy reports it. */ val name: String?, /** Whether the login is still in the game. */ val online: Boolean, @@ -141,7 +163,30 @@ public data class LoginInfo( * False while online means no decoder was hooked for this login, so its packets never reach the log. */ val transcribing: Boolean, -) + /** The newest server tick that a packet was decoded in, or null before the first packet. */ + val tick: Int?, +) { + internal companion object { + /** Build the login of the given epoch from what the proxy knows the moment the server accepts it. */ + internal fun of( + epoch: Int, + header: BinaryHeader, + ): LoginInfo = + LoginInfo( + epoch = epoch, + revision = header.revision, + world = header.worldId, + host = header.worldHost, + localPlayerIndex = header.localPlayerIndex, + connectedAt = Instant.ofEpochMilli(header.timestamp).toString(), + captureFile = null, + name = null, + online = true, + transcribing = false, + tick = null, + ) + } +} /** The newest login of a session. Updates from an older login are ignored. */ internal class LoginRegistry { @@ -159,22 +204,16 @@ internal class LoginRegistry { @Synchronized fun current(): LoginInfo? = info - /** Register that the login of the given epoch is in the game, unless a newer login is known. */ + /** + * Register that the login is in the game, unless a newer login is known. + * A login that is known already, which the proxy reports again when it reconnects, only goes back online. + */ @Synchronized - fun login( - epoch: Int, - revision: Int, - world: Int, - ) { + fun login(login: LoginInfo) { val current = info - if (current != null && current.epoch > epoch) return + if (current != null && current.epoch > login.epoch) return - info = - if (current != null && current.epoch == epoch) { - current.copy(online = true) - } else { - LoginInfo(epoch, revision, world, name = null, online = true, transcribing = false) - } + info = if (current != null && current.epoch == login.epoch) current.copy(online = true) else login } /** Apply the change to the newest login when it is the one of the given epoch. */ @@ -188,24 +227,26 @@ internal class LoginRegistry { } } -/** What the session tools return. */ +/** What the session tools return. A value that does not apply, or is not known yet, is left out of the JSON. */ @JsonInclude(JsonInclude.Include.NON_NULL) public data class SessionSnapshot( /** The id of the session. */ val session: String, + /** Who owns the client: `launched` when the MCP server started it, `attached` when the rsprox GUI did. */ + val kind: String, /** The name of the proxy target the session belongs to. */ val target: String, - /** The state of the client: `stopped`, `launching` or `connected`. */ + /** The state: `stopped`, `launching` or `connected` when launched, `attached` or `ended` when attached. */ val state: String, /** The reason the session has no client, or null while it has one. */ val reason: String?, - /** The number of the current launch, or null while stopped. */ + /** The number of the current launch, or null while stopped and for an attached session. */ val generation: Int?, - /** The proxy port of the current launch, or null while stopped. */ + /** The proxy port of the client, or null while a launched session is stopped. */ val proxyPort: Int?, - /** The HTTP port of the current launch, or null while stopped. */ + /** The HTTP port of the current launch, or null while stopped and for an attached session. */ val httpPort: Int?, - /** The process id of the client, or null unless connected. */ + /** The process id of the client, or null unless a launched session is connected. */ val pid: Long?, /** The newest login, or null before the first one. */ val login: LoginInfo?, @@ -213,11 +254,15 @@ public data class SessionSnapshot( val cursor: Long, ) -/** One client of a proxy target, with its packet log and its logins, across every launch of that client. */ -public class Session internal constructor( +/** + * One client of a proxy target, with its packet log and its logins. + * A session is either launched, and its client is the MCP server's to drive and to stop, or attached + * to a client that the rsprox GUI launched, which the MCP server only reads the packets of. + */ +public sealed class Session( /** The id that callers name the session by. */ public val id: SessionId, - /** The proxy target that every launch of the session is for. */ + /** The proxy target that the client of the session is for. */ public val target: ProxyTargetConfig, ) { /** The packet log. Owned by the session, not a launch, so a restart keeps the records and the cursor space. */ @@ -226,6 +271,29 @@ public class Session internal constructor( /** The logins of the session. */ internal val logins = LoginRegistry() + /** + * Get the link to the plugin in the client, for the client tool named [tool]. + * Throws a [ToolError] that says why the tool cannot reach the client. + */ + internal abstract fun link(tool: String): BridgeLink + + /** Get what the session tools report of the session at this moment. */ + public abstract fun snapshot(): SessionSnapshot + + /** Append a lifecycle marker to the packet log. */ + protected fun mark( + prot: String, + text: String, + ) { + packets.append(logins.current()?.epoch ?: 0, 0, Origin.PROXY, prot, text) + } +} + +/** A session whose client the MCP server launched, across every launch of that client. */ +public class LaunchedSession internal constructor( + id: SessionId, + target: ProxyTargetConfig, +) : Session(id, target) { /** The lock that makes each state transition and its marker one step. */ private val lock = ReentrantLock() @@ -281,7 +349,7 @@ public class Session internal constructor( } /** Get the link of the connected client. Throws a [ToolError] that says what to do about its absence. */ - internal fun requireLink(): BridgeLink = + override fun link(tool: String): BridgeLink = when (val state = client) { is ClientState.Connected -> state.link is ClientState.Launching -> @@ -291,7 +359,7 @@ public class Session internal constructor( } /** Get what the session tools report of the session at this moment. */ - public fun snapshot(): SessionSnapshot { + override fun snapshot(): SessionSnapshot { val state = client val launch = when (state) { @@ -302,6 +370,7 @@ public class Session internal constructor( return SessionSnapshot( session = id.value, + kind = "launched", target = target.name, state = when (state) { @@ -321,16 +390,70 @@ public class Session internal constructor( /** Append the lifecycle marker of the state to the packet log. */ private fun mark(state: ClientState) { - val (prot, text) = - when (state) { - is ClientState.Launching -> - "CLIENT_LAUNCHED" to - "generation=${state.launch.generation} proxyPort=${state.launch.proxyPort} " + - "httpPort=${state.launch.httpPort}" - is ClientState.Connected -> "CLIENT_CONNECTED" to "pid=${state.pid}" - is ClientState.Stopped -> "CLIENT_EXITED" to state.reason - } + when (state) { + is ClientState.Launching -> + mark( + "CLIENT_LAUNCHED", + "generation=${state.launch.generation} proxyPort=${state.launch.proxyPort} " + + "httpPort=${state.launch.httpPort}", + ) + is ClientState.Connected -> mark("CLIENT_CONNECTED", "pid=${state.pid}") + is ClientState.Stopped -> mark("CLIENT_EXITED", state.reason) + } + } +} - packets.append(logins.current()?.epoch ?: 0, 0, Origin.PROXY, prot, text) +/** + * A session of a client that the rsprox GUI launched. The client belongs to whoever runs the GUI, so + * the MCP server reads its packets and never stops or drives it. + */ +public class AttachedSession internal constructor( + id: SessionId, + target: ProxyTargetConfig, + /** The port the client reaches the proxy on. */ + public val proxyPort: Int, +) : Session(id, target) { + /** The reason the session ended, or null while the client runs. */ + @Volatile + private var ended: String? = null + + init { + mark("CLIENT_ATTACHED", "proxyPort=$proxyPort") + } + + /** End the session for [reason], since its client is gone. Its packets stay readable. Idempotent. */ + @Synchronized + internal fun end(reason: String) { + if (ended != null) return + + ended = reason + mark("CLIENT_EXITED", reason) + } + + /** Refuse the tool, since no client tool reaches the client of an attached session yet. */ + override fun link(tool: String): BridgeLink = throw ToolError(notAvailable(tool)) + + /** Build the refusal of a tool that acts on the client, for the error of that tool. */ + internal fun notAvailable(tool: String): String = + "$tool is not available for an attached session: session $id belongs to a client that was " + + "launched from the rsprox GUI, so only its packets can be read" + + /** Get what the session tools report of the session at this moment. */ + override fun snapshot(): SessionSnapshot { + val reason = ended + + return SessionSnapshot( + session = id.value, + kind = "attached", + target = target.name, + state = if (reason == null) "attached" else "ended", + reason = reason, + generation = null, + proxyPort = proxyPort, + httpPort = null, + pid = null, + login = logins.current(), + cursor = packets.head().seq, + ) } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index b83be666..a213f2b4 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -41,7 +41,10 @@ internal class Reservation( val launcherExited: () -> Boolean, ) -/** Creates the sessions, launches and stops their clients, and feeds what happens to a client into its session. */ +/** + * Creates the sessions and feeds what happens to a client into its session. It launches and stops the + * clients of the launched sessions, and attaches a session to each client that something else launched. + */ public class SessionManager internal constructor( /** The part of the proxy that launches and kills clients. */ private val launcher: ClientLauncher, @@ -54,7 +57,7 @@ public class SessionManager internal constructor( /** The longest wait for the plugin of a launched client to say hello. */ private val helloTimeoutMs: Long = DEFAULT_HELLO_TIMEOUT_MS, ) { - /** The sessions in the order they were first launched. */ + /** The sessions in the order they were first launched or attached. Added to under its own monitor. */ private val sessions = CopyOnWriteArrayList() /** The lock that makes launches and stops take turns, since the proxy's launch path is not re-entrant. */ @@ -80,13 +83,14 @@ public class SessionManager internal constructor( ): SessionSnapshot { val started = synchronized(launchLock) { - val existing = session?.let { resolve(it) } + val existing = session?.let { launched(resolve(it), "session_start") } if (existing != null) requireTarget(existing, target) - val current = existing ?: Session(SessionId("s${sessions.size + 1}"), resolveTarget(target)) - if (current.client is ClientState.Stopped) launch(current) - - current + if (existing == null || existing.client is ClientState.Stopped) { + launch(existing, existing?.target ?: resolveTarget(target)) + } else { + existing + } } started.awaitConnected(waitMs) @@ -94,9 +98,12 @@ public class SessionManager internal constructor( return started.snapshot() } - /** Kill the client. The session and its packets stay listed and readable. Idempotent. */ + /** + * Kill the client of a launched session. The session and its packets stay listed and readable. Idempotent. + * Throws [ToolError] for an attached session, whose client is not the MCP server's to kill. + */ public fun stop(session: String?): SessionSnapshot { - val resolved = resolve(session) + val resolved = launched(resolve(session), "session_stop") synchronized(launchLock) { val state = resolved.client @@ -119,6 +126,29 @@ public class SessionManager internal constructor( return resolved.snapshot() } + /** + * Create the session of a client that something other than this manager is launching on [proxyPort], + * and return the monitor that records its packets. Returns null for a launch of this manager's own, + * which has its session already. + */ + internal fun attach( + proxyPort: Int, + target: ProxyTargetConfig, + ): SessionMonitor? { + if (launchedSessions().any { it.client.proxyPort() == proxyPort }) return null + + val session = register { id -> AttachedSession(id, target, proxyPort) } + + return tap(session) + } + + /** End the session that is attached to the client on [proxyPort], if any. Idempotent. */ + internal fun detach(proxyPort: Int) { + val attached = sessions.filterIsInstance().firstOrNull { it.proxyPort == proxyPort } + + attached?.end("the client was closed") + } + /** Get a snapshot of every session. */ public fun list(): List = sessions.map { it.snapshot() } @@ -132,7 +162,12 @@ public class SessionManager internal constructor( ?: throw ToolError("no session '$ref'. Sessions: ${sessionIds()}") } - if (sessions.isEmpty()) throw ToolError("no session exists yet; call session_start first") + if (sessions.isEmpty()) { + throw ToolError( + "no session exists yet; call session_start, or launch a client from the rsprox GUI when it " + + "serves this endpoint", + ) + } return sessions.singleOrNull() ?: throw ToolError("several sessions exist; pass one of: ${sessionIds()}") } @@ -140,12 +175,41 @@ public class SessionManager internal constructor( /** Get the names of the proxy targets. */ public fun targets(): List = launcher.targets().map { it.name } + /** Build the monitor that records the packets and the logins of the client of the session. */ + private fun tap(session: Session): PacketTap = + PacketTap(session.packets, session.logins, settings, session.target.binaryFolder) + + /** Get the launched sessions. */ + private fun launchedSessions(): List = sessions.filterIsInstance() + + /** + * Get the session as a launched one, for a tool that needs the client to be the MCP server's own. + * Throws [ToolError] for an attached session. + */ + private fun launched( + session: Session, + tool: String, + ): LaunchedSession = + when (session) { + is LaunchedSession -> session + is AttachedSession -> throw ToolError(session.notAvailable(tool)) + } + + /** + * Make a session under the next id and list it. Launches and attaches number their sessions through + * here, and not under [launchLock], which a launch holds while the proxy calls back into [attach]. + */ + private fun register(create: (SessionId) -> S): S = + synchronized(sessions) { + create(SessionId("s${sessions.size + 1}")).also { sessions += it } + } + /** List the session ids for an error message. */ private fun sessionIds(): String = sessions.joinToString(", ") { it.id.value }.ifEmpty { "none" } /** Throw a [ToolError] when the caller names a target other than the one the session belongs to. */ private fun requireTarget( - session: Session, + session: LaunchedSession, target: String?, ) { if (target == null || session.target.name.equals(target, ignoreCase = true)) return @@ -167,34 +231,43 @@ public class SessionManager internal constructor( } /** - * Launch a client for the session and wait until its launcher has completed the handshake. + * Launch a client of the target for [stopped], or for a new session when it is null, and wait until its + * launcher has completed the handshake. A new session is listed once the ports of its launch are reserved. * Throws [ToolError] when the launch fails or hangs. Must be called with [launchLock] held. */ - private fun launch(session: Session) { + private fun launch( + stopped: LaunchedSession?, + target: ProxyTargetConfig, + ): LaunchedSession { if (hungLaunch) throw ToolError(HUNG_LAUNCH) - val reservation = reserve(session) + val reservation = reserve(target) + val session = stopped ?: register { id -> LaunchedSession(id, target) } val launch = Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort) - sessions.addIfAbsent(session) - // Registered before the client is forked: its hello can arrive before the launch call returns. bridge.expect(launch.httpPort, listener(session, launch)) session.apply(SessionEvent.Launched(launch)) - val reason = fork(session, reservation) ?: return expectHello(session, launch) + val reason = fork(session, reservation) + + if (reason == null) { + expectHello(session, launch) + + return session + } release(launch) session.apply(SessionEvent.Stop(reason)) throw ToolError("session ${session.id} failed to launch: $reason") } - /** Reserve the ports for a launch of the session. Throws [ToolError] when its target cannot be prepared. */ - private fun reserve(session: Session): Reservation = + /** Reserve the ports for a launch of the target. Throws [ToolError] when the target cannot be prepared. */ + private fun reserve(target: ProxyTargetConfig): Reservation = try { - launcher.reserve(session.target) + launcher.reserve(target) } catch (e: Exception) { - throw ToolError("could not prepare target '${session.target.name}': ${rootMessage(e)}") + throw ToolError("could not prepare target '${target.name}': ${rootMessage(e)}") } /** @@ -203,7 +276,7 @@ public class SessionManager internal constructor( * Must be called with [launchLock] held. */ private fun fork( - session: Session, + session: LaunchedSession, reservation: Reservation, ): String? { // The proxy waits for the launcher's handshake with no timeout, so the wait is bounded from outside. @@ -211,7 +284,7 @@ public class SessionManager internal constructor( val thread = Thread({ try { - reservation.launch(PacketTap(session.packets, session.logins, settings)) + reservation.launch(tap(session)) } catch (t: Throwable) { failure.set(t) } @@ -259,7 +332,7 @@ public class SessionManager internal constructor( /** Give the plugin of the launched client a bounded time to say hello. */ private fun expectHello( - session: Session, + session: LaunchedSession, launch: Launch, ) { val overdue = Runnable { abandon(session, launch, NEVER_CONNECTED) } @@ -269,7 +342,7 @@ public class SessionManager internal constructor( /** Stop the session for [reason] and kill its client, unless the session has moved on from waiting for it. */ private fun abandon( - session: Session, + session: LaunchedSession, launch: Launch, reason: String, ) { @@ -286,7 +359,7 @@ public class SessionManager internal constructor( /** Build the listener that feeds the hello and the close of one launch into its session. */ private fun listener( - session: Session, + session: LaunchedSession, launch: Launch, ): BridgeListener = object : BridgeListener { diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt new file mode 100644 index 00000000..7d268f59 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -0,0 +1,305 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.SessionManager +import net.rsprox.mcp.session.target +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.shared.SessionMonitor +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.ChildProperty +import net.rsprox.shared.property.RootProperty +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AttachedSessionTest { + private val launcher = FakeLauncher() + private val fixture = TestHub() + private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub) + private val server = McpHttpServer(0, tools { manager }, "1.2.3").also { it.start() } + private val http = HttpClient.newHttpClient() + + private val header = + BinaryHeader( + headerVersion = 1, + revision = 235, + subRevision = 1, + clientType = 1, + platformType = 1, + timestamp = 1_700_000_000_000, + worldId = 301, + worldFlags = 0, + worldLocation = 0, + worldHost = "127.0.1.3", + worldActivity = "", + localPlayerIndex = 7, + accountHash = ByteArray(0), + clientName = "RuneLite", + js5MasterIndex = ByteArray(0), + ) + + @AfterTest + fun cleanUp() { + server.close() + fixture.close() + } + + private fun toolResult( + tool: String, + arguments: String, + ): JsonNode { + val body = + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$tool","arguments":$arguments}}""" + + val request = + HttpRequest + .newBuilder(URI("http://127.0.0.1:${server.localPort}/mcp")) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + + val response = http.send(request, HttpResponse.BodyHandlers.ofString()) + + return McpDispatcher.MAPPER.readTree(response.body()).get("result") + } + + private fun call( + tool: String, + arguments: String = "{}", + ): String { + val result = toolResult(tool, arguments) + val text = result.get("content").single().get("text").asText() + assertEquals(false, result.get("isError").asBoolean(), text) + + return text + } + + private fun error( + tool: String, + arguments: String, + ): String { + val result = toolResult(tool, arguments) + val text = result.get("content").single().get("text").asText() + assertEquals(true, result.get("isError").asBoolean(), text) + + return text + } + + private fun sessions(): List = + McpDispatcher.MAPPER + .readTree(call("session_list")) + .get("sessions") + .map { it.toString() } + + /** Attaches the way the proxy does when the GUI launches a client on the port. */ + private fun attach( + proxyPort: Int = 43701, + captureFolder: String? = "Local", + ): SessionMonitor = + checkNotNull(manager.attach(proxyPort, target(1, "Local").copy(binaryFolder = captureFolder))) + + /** Reports a login the way the proxy does, up to the point where the recording is set up. */ + private fun SessionMonitor.logIn(): SessionMonitor { + val login = forSession(header) + login.onLogin(header) + login.onCacheUpdate { error("the test formats no packet that needs the cache") } + + return login + } + + private fun SessionMonitor.packet( + tick: Int, + direction: StreamDirection, + prot: String, + ) { + onPacketDirection(direction) + onTranscribe( + tick, + object : RootProperty { + override val prot = prot + override val children = mutableListOf>() + }, + ) + } + + private fun rows(arguments: String): List = call("packets_read", arguments).lines().drop(1) + + @Test + fun `a client the GUI launches is listed as an attached session, numbered with the launched ones`() { + call("session_start", """{"target":"My Server","wait_ms":0}""") + + attach(proxyPort = 43701) + + assertEquals( + listOf( + """{"session":"s1","kind":"launched","target":"My Server","state":"launching","generation":1,""" + + """"proxyPort":43751,"httpPort":43650,"cursor":1}""", + """{"session":"s2","kind":"attached","target":"Local","state":"attached",""" + + """"proxyPort":43701,"cursor":1}""", + ), + sessions(), + ) + } + + @Test + fun `a client that the MCP server launched itself gets no attached session`() { + call("session_start", """{"target":"My Server","wait_ms":0}""") + + assertNull(manager.attach(FakeLauncher.FIRST_PROXY_PORT, target(1, "My Server"))) + + assertEquals(listOf("s1"), manager.list().map { it.session }) + } + + @Test + fun `the session list reports each fact of a login from the moment the proxy knows it`() { + val client = attach() + val attached = """{"session":"s1","kind":"attached","target":"Local","state":"attached","proxyPort":43701""" + val accepted = + """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + + """"connectedAt":"2023-11-14T22:13:20Z"""" + + assertEquals(listOf("""$attached,"cursor":1}"""), sessions()) + + val login = client.forSession(header) + login.onLogin(header) + + assertEquals(listOf("""$attached,$accepted,"online":true,"transcribing":false},"cursor":2}"""), sessions()) + + login.onCacheUpdate { error("the test formats no packet that needs the cache") } + val recorded = """$accepted,"captureFile":"Local/${header.fileName()}"""" + + assertEquals(listOf("""$attached,$recorded,"online":true,"transcribing":false},"cursor":2}"""), sessions()) + + login.packet(tick = 4, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + + assertEquals( + listOf("""$attached,$recorded,"online":true,"transcribing":true,"tick":4},"cursor":3}"""), + sessions(), + ) + + login.onNameUpdate("Alice") + login.packet(tick = 5, StreamDirection.CLIENT_TO_SERVER, "NO_TIMEOUT") + + assertEquals( + listOf("""$attached,$recorded,"name":"Alice","online":true,"transcribing":true,"tick":5},"cursor":4}"""), + sessions(), + ) + + login.onLogout(header) + + assertEquals( + listOf("""$attached,$recorded,"name":"Alice","online":false,"transcribing":true,"tick":5},"cursor":5}"""), + sessions(), + ) + } + + @Test + fun `a login of a target that is not recorded has no capture file`() { + attach(captureFolder = null).logIn() + + assertEquals( + listOf( + """{"session":"s1","kind":"attached","target":"Local","state":"attached","proxyPort":43701,""" + + """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + + """"connectedAt":"2023-11-14T22:13:20Z","online":true,"transcribing":false},"cursor":2}""", + ), + sessions(), + ) + } + + @Test + fun `the packets of an attached session are read by cursor, with a new login number for each login`() { + val client = attach() + val first = client.logIn() + first.packet(tick = 4, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + first.packet(tick = 5, StreamDirection.CLIENT_TO_SERVER, "NO_TIMEOUT") + first.onLogout(header) + client.logIn().packet(tick = 1, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + + // The formatter ends the name of a packet without properties with a space. + assertEquals( + listOf( + """{"next":7,"head":7,"dropped":0,"timedOut":false,"count":7}""", + "1 L0 T0 P CLIENT_ATTACHED proxyPort=43701", + "2 L1 T0 P LOGIN revision=235 world=301", + "3 L1 T4 S REBUILD_NORMAL [rebuild_normal] ", + "4 L1 T5 C NO_TIMEOUT [no_timeout] ", + "5 L1 T0 P LOGOUT world=301", + "6 L2 T0 P LOGIN revision=235 world=301", + "7 L2 T1 S REBUILD_NORMAL [rebuild_normal] ", + ), + call("packets_read", """{"session":"s1"}""").lines(), + ) + + assertEquals(listOf("4 L1 T5 C NO_TIMEOUT [no_timeout] "), rows("""{"after":3,"origin":"client"}""")) + } + + @Test + fun `session_stop and session_start refuse an attached session and leave its client alone`() { + attach() + val refusal = + "is not available for an attached session: session s1 belongs to a client that was launched " + + "from the rsprox GUI, so only its packets can be read" + + assertEquals("session_stop $refusal", error("session_stop", """{"session":"s1"}""")) + assertEquals("session_start $refusal", error("session_start", """{"session":"s1","wait_ms":0}""")) + + assertEquals(emptyList(), launcher.killed) + assertEquals(emptyList(), launcher.reserved) + assertEquals("attached", manager.list().single().state) + } + + @Test + fun `every client tool refuses an attached session`() { + attach() + val required = + mapOf( + "client_login" to """{"username":"alice","password":"any"}""", + "client_type" to """{"text":"hello"}""", + "client_interact" to """{"target":"tile","x":3094,"y":3107}""", + ) + + val clientTools = tools { manager }.map { it.name }.filter { it.startsWith("client_") } + + assertEquals(10, clientTools.size) + + for (tool in clientTools) { + assertEquals( + "$tool is not available for an attached session: session s1 belongs to a client that was " + + "launched from the rsprox GUI, so only its packets can be read", + error(tool, required[tool] ?: "{}"), + ) + } + } + + @Test + fun `an attached session ends when its client is closed and stays listed with its packets`() { + attach(proxyPort = 43701).logIn() + attach(proxyPort = 43702) + + manager.detach(43701) + manager.detach(43701) + manager.detach(43999) + + assertEquals( + listOf("ended" to "the client was closed", "attached" to null), + manager.list().map { it.state to it.reason }, + ) + + assertEquals( + listOf( + "1 L0 T0 P CLIENT_ATTACHED proxyPort=43701", + "2 L1 T0 P LOGIN revision=235 world=301", + "3 L1 T0 P CLIENT_EXITED the client was closed", + ), + rows("""{"session":"s1"}"""), + ) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt index d4fa35bc..e2fe0c64 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt @@ -182,7 +182,7 @@ class SessionManagerRecoveryTest { assertEquals(listOf("connected", "stopped"), manager.list().map { it.state }) assertEquals("connected", manager.start(null, "s1", 0).state) - val link = manager.resolve("s1").requireLink() + val link = manager.resolve("s1").link("client_state") assertEquals(7, link.call("state", McpDispatcher.MAPPER.createObjectNode(), 10_000).get("tick").asInt()) assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), manager.markers("s2")) assertEquals("stopped by caller", manager.stop("s1").reason) From 7cafac60bbc600d250d695d6aed4bb8139a683ce Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 10:24:23 +0200 Subject: [PATCH 22/72] feat(mcp): serve the endpoint from inside the GUI `./gradlew proxy` now serves the MCP endpoint on the port and path of the standalone server, from the moment the proxy has started. Every RuneLite or native client that is launched from the GUI becomes an attached session, so an agent can read the packets of a session that the owner plays by hand. The module registers a proxy extension, which the GUI finds through ServiceLoader. The GUI therefore knows nothing of the MCP module, and only the root project, whose classpath the `proxy` task runs with, depends on it. The extension binds the port before anything else. When the port is taken, by a second rsprox or by the standalone server, it logs one line and the GUI starts without the endpoint. The endpoint leaves the filter and setting stores of the GUI alone, so inside the GUI the packet logs hold the GUI's filtered view. Only the standalone server swaps in its unfiltered stores, as before. The standalone server takes its default port from the proxy, which the GUI's endpoint reads it from as well. Co-Authored-By: Claude Opus 5.5 --- build.gradle.kts | 2 + .../kotlin/net/rsprox/gui/ProxyToolGui.kt | 2 + .../kotlin/net/rsprox/mcp/McpExtension.kt | 113 ++++++++++++++++++ mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 45 ++++--- .../services/net.rsprox.proxy.ProxyExtension | 1 + .../kotlin/net/rsprox/mcp/McpExtensionTest.kt | 74 ++++++++++++ 6 files changed, 223 insertions(+), 14 deletions(-) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt create mode 100644 mcp/src/main/resources/META-INF/services/net.rsprox.proxy.ProxyExtension create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index d3e234d4..7026f8bb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,6 +71,8 @@ subprojects { dependencies { runtimeOnly(projects.gui.proxyTool) runtimeOnly(projects.proxy) + // Found by the GUI through ServiceLoader, which is how the `proxy` task serves the MCP endpoint. + runtimeOnly(projects.mcp) } buildscript { diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt index 3c004c06..f3b0451b 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt @@ -1,5 +1,6 @@ package net.rsprox.gui +import net.rsprox.proxy.ProxyExtension import java.util.Locale import javax.swing.ImageIcon import javax.swing.SwingUtilities @@ -28,6 +29,7 @@ public fun main(args: Array) { App.service.start(rspsJavConfigUrl, rspsModulus) { percentage, actionText, subActionText, progressText -> SplashScreen.stage(percentage, actionText, subActionText, progressText) } + ProxyExtension.startAll(App.service) SplashScreen.stop() SwingUtilities.invokeLater { // Disable icons in all trees. diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt new file mode 100644 index 00000000..2de176e1 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -0,0 +1,113 @@ +package net.rsprox.mcp + +import com.github.michaelbull.logging.InlineLogger +import net.rsprox.mcp.bridge.Rendering +import net.rsprox.mcp.server.McpHttpServer +import net.rsprox.mcp.server.ToolError +import net.rsprox.mcp.server.tools +import net.rsprox.mcp.session.SessionManager +import net.rsprox.proxy.ClientListener +import net.rsprox.proxy.ProxyExtension +import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference + +/** + * Serves the MCP endpoint from inside the rsprox GUI, which finds this class through `META-INF/services`. + * Every client that is launched from the GUI becomes an attached session. The proxy keeps the filter and + * setting stores of the GUI, so the packet logs hold what the GUI shows. + */ +public class McpExtension : ProxyExtension { + /** Serve the endpoint on the configured port, unless it is turned off or the port is taken. */ + override fun start(service: ProxyService) { + if (!service.getMcpEnabled()) { + logger.info { "The MCP endpoint is turned off by mcp.enabled in proxy.properties" } + + return + } + + serveOrNull(service.getMcpPort()) { opened -> + // The GUI shares the proxy's port range with this endpoint, so no port is left unused for it. + val manager = sessionManager(service, portSkip = 0, sideloadDir = null, Rendering.GPU, opened) + + service.addClientListener(AttachingListener(manager)) + + manager + } + } + + private companion object { + /** The logger of the extension. */ + private val logger = InlineLogger() + } +} + +/** Attaches a session to each client the proxy launches, and ends it when the client is gone. */ +private class AttachingListener( + /** The sessions that the clients are attached to. */ + private val sessions: SessionManager, +) : ClientListener { + /** Attach a session to the client, unless the session manager launched it itself. */ + override fun onClientLaunch( + port: Int, + target: ProxyTargetConfig, + ): SessionMonitor? = sessions.attach(port, target) + + /** End the session that is attached to the client, if any. */ + override fun onClientClosed(port: Int) { + sessions.detach(port) + } +} + +/** + * Bind the endpoint to [port] and then build the sessions it serves, which may add what must be closed + * on a failure to the list they are given. Returns null when the port is taken or the sessions cannot be + * built. Either is logged and neither is thrown, since the endpoint is no reason for its host to stop. + */ +internal fun serveOrNull( + port: Int, + sessions: (opened: MutableList) -> SessionManager, +): McpHttpServer? { + val built = AtomicReference() + val http = + McpHttpServer( + port, + tools { built.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, + System.getenv("APP_VERSION") ?: "dev", + ) + + // Binding first keeps a second instance from touching the rendezvous file of the one that serves. + try { + http.start() + } catch (e: IOException) { + endpointLogger.warn { + "The MCP endpoint is not served: 127.0.0.1:$port cannot be bound (${e.message}). Another rsprox or " + + "the standalone MCP server may hold it. Set mcp.port or mcp.enabled in proxy.properties." + } + + return null + } + + try { + built.set( + closingOnFailure { opened -> + opened += http + sessions(opened) + }, + ) + } catch (e: Exception) { + endpointLogger.error(e) { "The MCP endpoint could not start and is not served" } + + return null + } + + endpointLogger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } + + return http +} + +/** The logger of the endpoint. */ +private val endpointLogger = InlineLogger() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 0a2f7478..2a0a96d0 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -19,6 +19,7 @@ import net.rsprox.mcp.server.tools import net.rsprox.mcp.session.ProxyServiceLauncher import net.rsprox.mcp.session.SessionManager import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.config.DEFAULT_MCP_PORT import java.nio.file.Path import java.util.Locale import java.util.concurrent.atomic.AtomicReference @@ -27,7 +28,7 @@ import kotlin.system.exitProcess /** Runs rsprox without its GUI and serves it to an agent as an MCP server on loopback. */ public class McpCommand : CliktCommand(name = "mcp") { /** The loopback port of the MCP endpoint. */ - private val port by option("--port", help = "Loopback port of the MCP endpoint").int().default(43580) + private val port by option("--port", help = "Loopback port of the MCP endpoint").int().default(DEFAULT_MCP_PORT) /** The number of proxy ports left unused at the start of the range. */ private val portSkip by option( @@ -97,21 +98,11 @@ public class McpCommand : CliktCommand(name = "mcp") { logger.debug { "Starting proxy service: $subActionText (${(percentage * 100).toInt()}%)" } } + // This process has no GUI to filter in, so its log holds every packet. service.filterSetStore = UnfilteredFilterSetStore service.settingsStore = TapSettingSetStore - val launcher = ProxyServiceLauncher(service, portSkip.coerceAtLeast(1), BridgeJar(sideloadDir)) - // The plugin reads the same path in McpBridgePlugin.java. - val rendezvous = Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json") - val hub = BridgeHub(rendezvous, rendering) - - opened += hub - hub.start() - - // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. - Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) - - return SessionManager(launcher, service.settingsStore, hub) + return sessionManager(service, portSkip.coerceAtLeast(1), sideloadDir, rendering, opened) } private companion object { @@ -120,11 +111,37 @@ public class McpCommand : CliktCommand(name = "mcp") { } } +/** + * Start the bridge hub and build the sessions of a started proxy, with the stores the proxy holds at + * this moment. Adds what must be closed when a later step fails to [opened]. + */ +internal fun sessionManager( + service: ProxyService, + portSkip: Int, + sideloadDir: Path?, + rendering: Rendering, + opened: MutableList, +): SessionManager { + val launcher = ProxyServiceLauncher(service, portSkip, BridgeJar(sideloadDir)) + + // The plugin reads the same path in McpBridgePlugin.java. + val rendezvous = Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json") + val hub = BridgeHub(rendezvous, rendering) + + opened += hub + hub.start() + + // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. + Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) + + return SessionManager(launcher, service.settingsStore, hub) +} + /** * Run the startup steps in [body], and close what they added to the list, newest first, when one of them fails. * The failure is passed on. */ -private fun closingOnFailure(body: (opened: MutableList) -> T): T { +internal fun closingOnFailure(body: (opened: MutableList) -> T): T { val opened = ArrayList() try { diff --git a/mcp/src/main/resources/META-INF/services/net.rsprox.proxy.ProxyExtension b/mcp/src/main/resources/META-INF/services/net.rsprox.proxy.ProxyExtension new file mode 100644 index 00000000..40b63662 --- /dev/null +++ b/mcp/src/main/resources/META-INF/services/net.rsprox.proxy.ProxyExtension @@ -0,0 +1 @@ +net.rsprox.mcp.McpExtension diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt new file mode 100644 index 00000000..59c97d57 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt @@ -0,0 +1,74 @@ +package net.rsprox.mcp + +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.server.McpHttpServer +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.SessionManager +import net.rsprox.proxy.ProxyExtension +import java.net.ServerSocket +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.ServiceLoader +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class McpExtensionTest { + private fun freePort(): Int = ServerSocket(0).use { it.localPort } + + @Test + fun `the GUI finds the extension among the proxy extensions on its classpath`() { + val found = ServiceLoader.load(ProxyExtension::class.java).map { it.javaClass } + + assertEquals(listOf>(McpExtension::class.java), found) + } + + @Test + fun `a port that is taken leaves no endpoint, builds no sessions and throws nothing`() { + val holder = McpHttpServer(0, emptyList(), "1.2.3").also { it.start() } + + try { + assertNull(serveOrNull(holder.localPort) { error("no session is built behind a port that is taken") }) + } finally { + holder.close() + } + } + + @Test + fun `sessions that cannot be built leave no endpoint, free the port and throw nothing`() { + val port = freePort() + + assertNull(serveOrNull(port) { throw IllegalStateException("the bridge hub could not start") }) + + ServerSocket(port).close() + } + + @Test + fun `a free port serves the tools over the sessions that were built`() { + val port = freePort() + val fixture = TestHub() + val endpoint = serveOrNull(port) { SessionManager(FakeLauncher(), TapSettingSetStore, fixture.hub) } + + try { + val request = + HttpRequest + .newBuilder(URI("http://127.0.0.1:$port/mcp")) + .POST( + HttpRequest.BodyPublishers.ofString( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_list"}}""", + ), + ).build() + + val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) + + assertContains(response.body(), """{\"sessions\":[],\"targets\":[\"Old School RuneScape\",\"My Server\"]}""") + } finally { + endpoint?.close() + fixture.close() + } + } +} From f31576b459ba90021df4f68e1edb67ea54d80b9b Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 10:24:23 +0200 Subject: [PATCH 23/72] docs(mcp): describe the endpoint inside the GUI and attached sessions Covers how to turn the endpoint off and move its port, what an attached session can and cannot do, that the GUI's filters decide which packets the log holds, and that anything on the machine can read the log, chat included. The standalone server can no longer share the default port with a GUI, so its section says how to run the two side by side. The live checklist gains the steps for an attached session. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 9 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index c84465cf..a37f9975 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -5,6 +5,10 @@ This module runs rsprox without its GUI and serves it to a coding agent as an reads the decoded packets in both directions, and looks at and drives the client. It is meant for checking a private server end to end with a real client. +The same endpoint also runs inside the rsprox GUI. There it attaches to the clients that you launch +by hand, so an agent can read the packets of your own session while you play. See "Run it inside the +GUI". + The server has two halves. The proxy half runs in this process and records every packet. The client half is a small RuneLite plugin that the server installs into the client's sideload directory before each launch. The plugin connects back to the server over loopback. The proxy only observes, so it @@ -18,8 +22,13 @@ server receives what it receives from a player. ``` The server listens on `http://127.0.0.1:43580/mcp`. It uses the proxy targets that the rsprox GUI -uses, from `~/.rsprox/proxy-targets.yaml`. The GUI may run at the same time. A session records to the -usual folder of its target, as it does when launched from the GUI. +uses, from `~/.rsprox/proxy-targets.yaml`. A session records to the usual folder of its target, as it +does when launched from the GUI. + +The GUI serves the same endpoint on the same port, so only one of the two can hold it. To run this +server next to a GUI, turn the GUI's endpoint off first, as described under "Run it inside the GUI". +Giving this server another port with `--port` is not enough, because both would write the same +rendezvous file. Each client opens a window, so the machine needs a display. A machine without one can use a virtual display, as described under "Run it without a display". @@ -44,6 +53,50 @@ not launch loads the plugin too, and the plugin then does nothing. The plugin fi the rendezvous file `~/.rsprox/mcp/bridge.json`. Every server of one user account writes that same file, so run one MCP server per user account. +## Run it inside the GUI + +``` +./gradlew proxy +``` + +The GUI serves the endpoint on `http://127.0.0.1:43580/mcp` from the moment the proxy has started. Two +lines in `~/.rsprox/proxy.properties` change that. Edit the file while the GUI is closed, because the +GUI writes the file again when it saves its own settings. + +| Property | Meaning | +|---|---| +| `mcp.enabled` | Whether the GUI serves the endpoint. Default `true`. Set it to `false` to turn the endpoint off. | +| `mcp.port` | Loopback port of the endpoint. Default 43580. | + +When the port is taken, by a second rsprox or by the standalone server, the GUI logs one line that +says so and runs without the endpoint. + +Every RuneLite or native client that you launch from the GUI appears in `session_list` as a session +with `"kind":"attached"`. Sessions are numbered in the order they appear, `s1`, `s2` and so on, +together with the sessions that `session_start` launches. An agent picks the client you mean by the +`target`, the `proxyPort` and, once you are logged in, the `world`, the display `name` and the other +values under `login`. The proxy never sees a login name or a password, so neither is listed. +RuneScape 3 clients are not attached. + +An attached session is read-only: + +- `packets_read` works on it as on any session, with the same cursors and the same `L` number + for each login. +- `session_stop`, `session_start` and every `client_*` tool refuse it with `not available for an + attached session`. The client is yours, so the server never stops it, clicks in it or reads its + screen. +- When you close the session's tab in the GUI, or the client exits, the session becomes + `"state":"ended"`. It stays listed and its packets stay readable. + +Inside the GUI the packet log holds the GUI's view, for attached sessions and for sessions that +`session_start` launches alike. The GUI's filters and settings decide which packets are transcribed +at all, so a packet that they hide is missing from `packets_read` too. Enable a filter in the GUI to +see its packets. The standalone server has no such filters and logs every packet. + +The endpoint has no password. Any program that runs under your user account, and any other user +of the machine, can reach it on loopback and read everything the log holds, including public and +private chat, for as long as the GUI runs. Turn `mcp.enabled` off when that is not acceptable. + ## Run it without a display RuneLite always opens a window, so on a machine with no screen give it a virtual one. macOS and @@ -90,7 +143,11 @@ it runs, whatever the clients do. ## Tools The tools come in three groups. The `session_*` tools launch a client for a target, stop it and list -the sessions. `session_start` gives the launcher of a client 180 seconds to complete its handshake. +the sessions. `session_list` tells the sessions apart: each has its id, its `kind` (`launched` or +`attached`), its target, its state and its proxy port. Once the client has logged in, `login` holds +the login number, the revision, the world and its host, the index of the local player, the time of +the login and whether the client is still logged in. The capture file, the display name and the +newest tick are added when the proxy learns them. `session_start` gives the launcher of a client 180 seconds to complete its handshake. When the launcher takes longer, the server abandons the launch and refuses further launches until it is restarted. `packets_read` reads the decoded packets of a session after a cursor, and can wait for the first packet that matches. The `client_*` tools look at the client and drive it: they read its @@ -106,7 +163,7 @@ curl -s http://127.0.0.1:43580/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/l Each packet is one line: ` L T `. `C` is client to server, `S` is server to client, and `P` is a marker that rsprox adds (`CLIENT_LAUNCHED`, `CLIENT_CONNECTED`, -`CLIENT_EXITED`, `LOGIN`, `LOGOUT`). The key codes of typed text are zeroed in the recorded packets, as +`CLIENT_ATTACHED`, `CLIENT_EXITED`, `LOGIN`, `LOGOUT`). The key codes of typed text are zeroed in the recorded packets, as in every rsprox recording. Read what was typed from the packet that carries the text, such as `RESUME_P_STRINGDIALOG`. @@ -147,7 +204,7 @@ three seconds, it takes the first matching packet after the cursor, and `proof` ``` 1. session_start {"target":"My Server"} - -> {"session":"s1","target":"My Server","state":"connected", ...} + -> {"session":"s1","kind":"launched","target":"My Server","state":"connected", ...} 2. client_login {"username":"mcptest","password":"any"} -> {"gameState":"LOGGED_IN","cursor":2} @@ -212,8 +269,8 @@ packet in step 5 reports `558:7`. ``` 1. session_start {"target":"My Server"} - -> {"session":"s1","target":"My Server","state":"connected","generation":1,"proxyPort":43751, - "httpPort":43650,"pid":40388,"cursor":2} + -> {"session":"s1","kind":"launched","target":"My Server","state":"connected","generation":1, + "proxyPort":43751,"httpPort":43650,"pid":40388,"cursor":2} 2. client_login {"username":"mcptest","password":"any"} -> {"gameState":"LOGGED_IN","cursor":2} @@ -244,7 +301,8 @@ packet in step 5 reports `558:7`. "truncated":false,"cursor":520} 9. session_stop {} - -> {"session":"s1","target":"My Server","state":"stopped","reason":"stopped by caller", ...} + -> {"session":"s1","kind":"launched","target":"My Server","state":"stopped", + "reason":"stopped by caller", ...} ``` Steps 5, 7 and 8 are three separate proofs. The client sent the click. The server answered the name @@ -264,7 +322,7 @@ log. The client comes back on new ports, and new packets are marked with the nex ## Check it against a live client The automated tests cover the packet log, the endpoint's HTTP and JSON-RPC handling, the tool calls -as an MCP client makes them, the session failure paths and the bridge handshake. They do not run a +as an MCP client makes them, the session failure paths, the attached sessions and the bridge handshake. They do not run a game client, so what the client does is checked by hand. Run this after a RuneLite update, a game revision change, or a change to the plugin. Each step names what proves it, so a step that fails names what changed. A tool that reports a dropped action, or lists what the client offered instead, @@ -294,3 +352,19 @@ Start the server and a session against a target you can log in to, with a new ac shows the turned view. 13. Killing the client process makes `session_list` show `stopped` with the reason `client exited`, and `session_start` with the same `session` brings it back with the earlier packets still readable. + +Then check the attached sessions. Stop the standalone server, start the GUI with `./gradlew proxy` +and launch a client from it by hand: + +1. `session_list` shows a session with `"kind":"attached"`, the target and the proxy port of the + client, and no `login`. +2. After you log in, `session_list` shows `login` with the world, the capture file and, a tick or two + later, your display name and a `tick` that advances. `packets_read` on the session returns the + packets that the GUI's own panel shows. +3. The GUI's panel shows the same packets as it does with `mcp.enabled=false`. +4. `session_stop` and `client_state` on the session fail with `not available for an attached + session`, and the client keeps running. +5. Closing the client window makes `session_list` show `"state":"ended"`. So does closing the tab of + a second client. Both sessions stay listed with their packets. +6. With the standalone server running first, the GUI starts as usual and logs that the endpoint is + not served. From 369c7969f82d9812269156eae7d41100cf231e01 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:10:12 +0200 Subject: [PATCH 24/72] docs(mcp): drop the comments the attach code does not need Remove the line comments that explain an ordering, a workaround or a default beside the code that already shows it, and the KDocs on the test helpers, which the other tests of the module do not carry. Correct three KDocs. ClientListener hears of RuneLite and native clients only, since a RuneScape 3 client has a monitor of its own type. FanOutSessionMonitor logs the first observer failure of each fan-out, so once for the client and once for each login. AttachedSession no longer says that client tools do not reach its client "yet". Co-Authored-By: Claude Opus 5.5 --- build.gradle.kts | 1 - mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt | 2 -- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 1 - mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt | 1 - mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt | 2 +- .../test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt | 3 --- proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt | 2 +- proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt | 1 - .../kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt | 5 +++-- proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt | 1 - 10 files changed, 5 insertions(+), 14 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7026f8bb..8a704a2f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,7 +71,6 @@ subprojects { dependencies { runtimeOnly(projects.gui.proxyTool) runtimeOnly(projects.proxy) - // Found by the GUI through ServiceLoader, which is how the `proxy` task serves the MCP endpoint. runtimeOnly(projects.mcp) } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt index 2de176e1..e76bcc8d 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -30,7 +30,6 @@ public class McpExtension : ProxyExtension { } serveOrNull(service.getMcpPort()) { opened -> - // The GUI shares the proxy's port range with this endpoint, so no port is left unused for it. val manager = sessionManager(service, portSkip = 0, sideloadDir = null, Rendering.GPU, opened) service.addClientListener(AttachingListener(manager)) @@ -79,7 +78,6 @@ internal fun serveOrNull( System.getenv("APP_VERSION") ?: "dev", ) - // Binding first keeps a second instance from touching the rendezvous file of the one that serves. try { http.start() } catch (e: IOException) { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 2a0a96d0..2ca8bd8a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -98,7 +98,6 @@ public class McpCommand : CliktCommand(name = "mcp") { logger.debug { "Starting proxy service: $subActionText (${(percentage * 100).toInt()}%)" } } - // This process has no GUI to filter in, so its log holds every packet. service.filterSetStore = UnfilteredFilterSetStore service.settingsStore = TapSettingSetStore diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt index 89bb2922..1944c4bc 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -118,7 +118,6 @@ private class LoginTap( override fun onCacheUpdate(cacheProvider: CacheProvider) { cache = cacheProvider - // The proxy hands the cache over right after it has set the recording of the login up. logins.update(epoch) { it.copy(captureFile = captureFile) } } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 7f7fe2c4..561467ad 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -430,7 +430,7 @@ public class AttachedSession internal constructor( mark("CLIENT_EXITED", reason) } - /** Refuse the tool, since no client tool reaches the client of an attached session yet. */ + /** Refuse the tool, since no client tool reaches the client of an attached session. */ override fun link(tool: String): BridgeLink = throw ToolError(notAvailable(tool)) /** Build the refusal of a tool that acts on the client, for the error of that tool. */ diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt index 7d268f59..31c210db 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -98,14 +98,12 @@ class AttachedSessionTest { .get("sessions") .map { it.toString() } - /** Attaches the way the proxy does when the GUI launches a client on the port. */ private fun attach( proxyPort: Int = 43701, captureFolder: String? = "Local", ): SessionMonitor = checkNotNull(manager.attach(proxyPort, target(1, "Local").copy(binaryFolder = captureFolder))) - /** Reports a login the way the proxy does, up to the point where the recording is set up. */ private fun SessionMonitor.logIn(): SessionMonitor { val login = forSession(header) login.onLogin(header) @@ -223,7 +221,6 @@ class AttachedSessionTest { first.onLogout(header) client.logIn().packet(tick = 1, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") - // The formatter ends the name of a packet without properties with a space. assertEquals( listOf( """{"next":7,"head":7,"dropped":0,"timedOut":false,"count":7}""", diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt index 7363e926..638295ea 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ClientListener.kt @@ -4,7 +4,7 @@ import net.rsprox.proxy.binary.BinaryHeader import net.rsprox.proxy.target.ProxyTargetConfig import net.rsprox.shared.SessionMonitor -/** Hears of every client that is launched with a session monitor, whoever launched it. */ +/** Hears of every RuneLite and native client that is launched with a session monitor, whoever launched it. */ public interface ClientListener { /** * Called before the client on [port] is started. A monitor that is returned hears every callback diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt index ed564f7f..f2aca2a1 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt @@ -782,7 +782,6 @@ public class ProxyService( notifyClientClosed(port) throw t } - // A launcher that handed its client over and exited before this point leaves nothing to watch. whenAllExit(processes[port].orEmpty()) { notifyClientClosed(port) } } diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt b/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt index b38463b1..1978a9cc 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/util/FanOutSessionMonitor.kt @@ -9,8 +9,9 @@ import java.util.concurrent.atomic.AtomicBoolean /** * Passes every callback to [primary] and then to each of [observers]. - * [primary] behaves as if it were the only monitor: what it throws is passed on. An observer that - * throws is logged once and otherwise ignored, so it can never keep a callback from another monitor. + * [primary] behaves as if it were the only monitor: what it throws is passed on. What an observer + * throws is ignored, so it can never keep a callback from another monitor. Each fan-out, the one of + * the client and the one of each login, logs only the first such failure. */ public class FanOutSessionMonitor( private val primary: SessionMonitor, diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt index f67a7b47..dc743fb1 100644 --- a/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt +++ b/proxy/src/test/kotlin/net/rsprox/proxy/ClientListenerTest.kt @@ -13,7 +13,6 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue -/** Runs until its input ends, so a test decides when it exits. */ object WaitsForInput { @JvmStatic fun main(args: Array) { From f2474b0ca80702540abaacc097a22e2679384ce7 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:19:54 +0200 Subject: [PATCH 25/72] refactor(mcp): build the standalone stores from the proxy's own The standalone server's filter and setting stores were two hand-written stores and two hand-written sets, with an empty override for every mutator. Each is now the proxy's default store holding one set that overrides only `get`, as the RuneScape 3 relay builds its stores. Outside the GUI the proxy, the transcriber and the formatters only ask a store for its active set, and a set for a filter, a setting or its regex filters. Those answers are unchanged: every filter on, no regex filters, and each setting at its default unless it drops output. Asking for the active set reads and writes no file, so the standalone server still persists nothing. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/packets/Unfiltered.kt | 205 ++---------------- 1 file changed, 21 insertions(+), 184 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt index 3ac4e4e1..71c22a87 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/Unfiltered.kt @@ -1,123 +1,29 @@ package net.rsprox.mcp.packets -import net.rsprox.shared.StreamDirection +import net.rsprox.proxy.filters.DefaultPropertyFilterSetStore +import net.rsprox.proxy.filters.UnmodifiablePropertyFilterSet +import net.rsprox.proxy.settings.DefaultSettingSetStore import net.rsprox.shared.filters.PropertyFilter import net.rsprox.shared.filters.PropertyFilterSet import net.rsprox.shared.filters.PropertyFilterSetStore -import net.rsprox.shared.filters.ProtCategory -import net.rsprox.shared.filters.RegexFilter +import net.rsprox.shared.settings.NopSettingSet import net.rsprox.shared.settings.Setting -import net.rsprox.shared.settings.SettingCategory -import net.rsprox.shared.settings.SettingGroup import net.rsprox.shared.settings.SettingSet import net.rsprox.shared.settings.SettingSetStore +import java.nio.file.Path /** * Every filter enabled, no regex filters, nothing persisted. The stores on disk belong to the GUI, - * and every mutator of the default filter set saves to them. + * and every mutator of the default filter set saves to them. Outside the GUI only the active set is + * asked for, which reads and writes no file. */ -internal object UnfilteredFilterSetStore : PropertyFilterSetStore { - /** The number of filter sets, which is always one. */ - override val size: Int = 1 +internal object UnfilteredFilterSetStore : + PropertyFilterSetStore by DefaultPropertyFilterSetStore(Path.of("."), mutableListOf(AllEnabled)) - /** Get the one filter set, since this store never makes another. */ - override fun create(name: String): PropertyFilterSet = AllEnabled - - /** Keep the one filter set and report that nothing was deleted. */ - override fun delete(index: Int): PropertyFilterSet? = null - - /** Get the one filter set, which sits at index 0. */ - override fun get(index: Int): PropertyFilterSet? = if (index == 0) AllEnabled else null - - /** Get the one filter set, which is always the active one. */ - override fun getActive(): PropertyFilterSet = AllEnabled - - /** Ignore the index, since the one filter set is always the active one. */ - override fun setActive(index: Int) { - // - } - - /** The one filter set of the store, in which every filter is on, so that no packet is omitted. */ - private object AllEnabled : PropertyFilterSet { - /** Get a creation time of zero, since the set was never saved. */ - override fun getCreationTime(): Long = 0 - - /** Get the fixed name of the set. */ - override fun getName(): String = "unfiltered" - - /** Ignore the name, which this in-memory set never shows. */ - override fun setName(name: String) { - // - } - - /** Delete nothing, since the set has no file. */ - override fun deleteBackingFile() { - // - } - - /** Report the filter as enabled, as every filter is. */ - override fun get(filter: PropertyFilter): Boolean = true - - /** Ignore the change, so the filter stays enabled. */ - override fun set( - filter: PropertyFilter, - enabled: Boolean, - ) { - // - } - - /** Ignore the change, so every filter of the category stays enabled. */ - override fun set( - category: ProtCategory, - enabled: Boolean, - ) { - // - } - - /** Ignore the change, so every filter of the direction stays enabled. */ - override fun set( - streamDirection: StreamDirection, - enabled: Boolean, - ) { - // - } - - /** Ignore the change, so every filter stays enabled. */ - override fun setAll(enabled: Boolean) { - // - } - - /** Keep every filter enabled, in place of the defaults that disable some. */ - override fun setDefaults() { - // - } - - /** Get an empty list, since the set has no regex filters. */ - override fun getRegexFilters(): List = emptyList() - - /** Ignore the filter, so the set stays without regex filters. */ - override fun addRegexFilter(regexFilter: RegexFilter) { - // - } - - /** Remove nothing, since the set holds no regex filters. */ - override fun removeRegexFilter(regexFilter: RegexFilter) { - // - } - - /** Replace nothing, since the set holds no regex filters. */ - override fun replaceRegexFilter( - oldRegexFilter: RegexFilter, - newRegexFilter: RegexFilter, - ) { - // - } - - /** Clear nothing, since the set holds no regex filters. */ - override fun clearRegexFilters() { - // - } - } +/** The one filter set of the store, in which every filter is on, so that no packet is omitted. */ +private object AllEnabled : PropertyFilterSet by UnmodifiablePropertyFilterSet() { + /** Report the filter as enabled, as every filter is. */ + override fun get(filter: PropertyFilter): Boolean = true } /** @@ -133,8 +39,12 @@ internal object UnfilteredFilterSetStore : PropertyFilterSetStore { * - `WORLDENTITY_INFO_HIDE_EMPTY`: an empty world entity info packet (`TextServerPacketTranscriber`). * - `HIDE_RS3_LOBBY`: RuneScape 3 lobby packets (`TextRs3Transcriber`). */ -internal object TapSettingSetStore : SettingSetStore { - /** The settings that drop output, which this store reports as off. */ +internal object TapSettingSetStore : + SettingSetStore by DefaultSettingSetStore(Path.of("."), mutableListOf(Fixed)) + +/** The one setting set of the store, whose values cannot be changed. */ +private object Fixed : SettingSet by NopSettingSet { + /** The settings that drop output, which this set reports as off. */ private val off = setOf( Setting.SKIP_FIRST_TICK, @@ -147,79 +57,6 @@ internal object TapSettingSetStore : SettingSetStore { Setting.HIDE_RS3_LOBBY, ) - /** The number of setting sets, which is always one. */ - override val size: Int = 1 - - /** Get the one setting set, since this store never makes another. */ - override fun create(name: String): SettingSet = Fixed - - /** Keep the one setting set and report that nothing was deleted. */ - override fun delete(index: Int): SettingSet? = null - - /** Get the one setting set, which sits at index 0. */ - override fun get(index: Int): SettingSet? = if (index == 0) Fixed else null - - /** Get the one setting set, which is always the active one. */ - override fun getActive(): SettingSet = Fixed - - /** Ignore the index, since the one setting set is always the active one. */ - override fun setActive(index: Int) { - // - } - - /** The one setting set of the store, whose values cannot be changed. */ - private object Fixed : SettingSet { - /** Get a creation time of zero, since the set was never saved. */ - override fun getCreationTime(): Long = 0 - - /** Get the fixed name of the set. */ - override fun getName(): String = "mcp" - - /** Ignore the name, which this in-memory set never shows. */ - override fun setName(name: String) { - // - } - - /** Delete nothing, since the set has no file. */ - override fun deleteBackingFile() { - // - } - - /** Determine if the setting is on: its default, unless it is one that drops output. */ - override fun get(setting: Setting): Boolean = setting.enabled && setting !in off - - /** Ignore the change, so the setting keeps its fixed value. */ - override fun set( - setting: Setting, - enabled: Boolean, - ) { - // - } - - /** Ignore the change, so every setting of the category keeps its fixed value. */ - override fun set( - category: SettingCategory, - enabled: Boolean, - ) { - // - } - - /** Ignore the change, so every setting of the group keeps its fixed value. */ - override fun set( - group: SettingGroup, - enabled: Boolean, - ) { - // - } - - /** Ignore the change, so every setting keeps its fixed value. */ - override fun setAll(enabled: Boolean) { - // - } - - /** Keep the fixed values, in place of the defaults that drop output. */ - override fun setDefaults() { - // - } - } + /** Determine if the setting is on: its default, unless it is one that drops output. */ + override fun get(setting: Setting): Boolean = setting.enabled && setting !in off } From ffb965f8365eead66e8d7ebc222fcda049bf87d1 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:19:54 +0200 Subject: [PATCH 26/72] refactor(mcp): delegate the callbacks the packet taps ignore PacketTap and LoginTap each spelled out the empty callbacks that the proxy's NopSessionMonitor already has, so both now delegate to it. Delegation also covers `forSession`, which LoginTap therefore overrides to keep returning itself. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/packets/PacketTap.kt | 72 ++----------------- 1 file changed, 6 insertions(+), 66 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt index 1944c4bc..eabf3822 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -4,6 +4,7 @@ import net.rsprox.cache.api.CacheProvider import net.rsprox.mcp.session.LoginInfo import net.rsprox.mcp.session.LoginRegistry import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.proxy.util.NopSessionMonitor import net.rsprox.shared.SessionMonitor import net.rsprox.shared.StreamDirection import net.rsprox.shared.property.OmitFilteredPropertyTreeFormatter @@ -26,56 +27,10 @@ internal class PacketTap( private val settings: SettingSetStore, /** The folder the proxy records the logins to, under its `binary` directory, or null when it records none. */ private val captureFolder: String?, -) : SessionMonitor { +) : SessionMonitor by NopSessionMonitor { /** Create the tap of one login, under the next login epoch. */ override fun forSession(header: BinaryHeader): SessionMonitor = LoginTap(log, logins, settings, logins.nextEpoch(), captureFolder?.let { "$it/${header.fileName()}" }) - - /** Ignore the login, which the proxy reports to the tap of that login. */ - override fun onLogin(header: BinaryHeader) { - // - } - - /** Ignore the logout, which the proxy reports to the tap of that login. */ - override fun onLogout(header: BinaryHeader) { - // - } - - /** Ignore the cache, which the proxy hands to the tap of each login. */ - override fun onCacheUpdate(cacheProvider: CacheProvider) { - // - } - - /** Ignore the incoming byte rate, which no tool reports. */ - override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { - // - } - - /** Ignore the outgoing byte rate, which no tool reports. */ - override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { - // - } - - /** Ignore the name, which the proxy reports to the tap of that login. */ - override fun onNameUpdate(name: String) { - // - } - - /** Ignore the user ids, which no tool reports. */ - override fun onUserInformationUpdate( - userId: Long, - userHash: Long, - ) { - // - } - - /** Ignore the packet, which the proxy reports to the tap of its login. */ - override fun onTranscribe( - cycle: Int, - property: RootProperty, - ) { - // - } } /** The session monitor of one login, which appends the packets of that login to the log as text. */ @@ -89,7 +44,7 @@ private class LoginTap( private val epoch: Int, /** The file the proxy records this login to, or null when it records none. */ private val captureFile: String?, -) : SessionMonitor { +) : SessionMonitor by NopSessionMonitor { /** * The game cache that the formatter looks names up in, or null before the proxy has handed it over. * Written on a Netty thread when the transcriber is hooked, read on the transcriber worker. @@ -114,6 +69,9 @@ private class LoginTap( }, ) + /** Get this tap, which is the monitor of its one login already. */ + override fun forSession(header: BinaryHeader): SessionMonitor = this + /** Keep the cache for the formatter, and register the file that the login is recorded to. */ override fun onCacheUpdate(cacheProvider: CacheProvider) { cache = cacheProvider @@ -174,22 +132,4 @@ private class LoginTap( override fun onNameUpdate(name: String) { logins.update(epoch) { it.copy(name = name) } } - - /** Ignore the user ids, which no tool reports. */ - override fun onUserInformationUpdate( - userId: Long, - userHash: Long, - ) { - // - } - - /** Ignore the incoming byte rate, which no tool reports. */ - override fun onIncomingBytesPerSecondUpdate(bytesPerLastSecond: Long) { - // - } - - /** Ignore the outgoing byte rate, which no tool reports. */ - override fun onOutgoingBytesPerSecondUpdate(bytesPerLastSecond: Long) { - // - } } From 93f711f5839bef8341d05682e5bbdfe8b07cbdee Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:19:54 +0200 Subject: [PATCH 27/72] refactor(mcp): give every client state its launch The proxy port of a client state and the launch in a session snapshot were the same switch over the three states. ClientState now has a `launch`, which is null while stopped, and both read it. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/session/Session.kt | 27 +++++++------------ .../net/rsprox/mcp/session/SessionManager.kt | 2 +- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 561467ad..87840ce6 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -34,22 +34,28 @@ public data class Launch( /** Whether a launched session has a client, and how far that client has come. */ public sealed interface ClientState { + /** The launch of the client, or null when the state has no client. */ + public val launch: Launch? + /** The session has no client. */ public data class Stopped( /** The reason the session has no client. */ val reason: String, - ) : ClientState + ) : ClientState { + /** Null, since the session has no client. */ + override val launch: Launch? get() = null + } /** The client was launched and its plugin has not said hello yet. */ public data class Launching( /** The launch whose client has not connected yet. */ - val launch: Launch, + override val launch: Launch, ) : ClientState /** The plugin in the client is connected, so the client can be driven. */ public data class Connected( /** The launch that the client belongs to. */ - val launch: Launch, + override val launch: Launch, /** The link to the plugin in the client. */ val link: BridgeLink, /** The process id of the client. */ @@ -57,14 +63,6 @@ public sealed interface ClientState { ) : ClientState } -/** Get the proxy port of the client, or null when the state has no client. */ -internal fun ClientState.proxyPort(): Int? = - when (this) { - is ClientState.Stopped -> null - is ClientState.Launching -> launch.proxyPort - is ClientState.Connected -> launch.proxyPort - } - /** What can change the [ClientState] of a launched session. Several threads raise these. [reduce] decides. */ public sealed interface SessionEvent { /** A launch of the client began. */ @@ -361,12 +359,7 @@ public class LaunchedSession internal constructor( /** Get what the session tools report of the session at this moment. */ override fun snapshot(): SessionSnapshot { val state = client - val launch = - when (state) { - is ClientState.Stopped -> null - is ClientState.Launching -> state.launch - is ClientState.Connected -> state.launch - } + val launch = state.launch return SessionSnapshot( session = id.value, diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index a213f2b4..babbe24d 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -135,7 +135,7 @@ public class SessionManager internal constructor( proxyPort: Int, target: ProxyTargetConfig, ): SessionMonitor? { - if (launchedSessions().any { it.client.proxyPort() == proxyPort }) return null + if (launchedSessions().any { it.client.launch?.proxyPort == proxyPort }) return null val session = register { id -> AttachedSession(id, target, proxyPort) } From e724ea97f5a7fc700944e1788c19132b44a59fd4 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:20:41 +0200 Subject: [PATCH 28/72] refactor(mcp): turn the session events into methods of the session SessionEvent had five variants, each built at one or two call sites and taken apart again by `reduce`. LaunchedSession now has one method for each, `launched`, `hello`, `linkClosed`, `stop` and `neverConnected`, which hold the same guards side by side over one private `transition`. Every rule is as it was: which states each one applies in, the check of the launch or the link it belongs to, the marker in the packet log and what each caller learns from it. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/session/Session.kt | 125 +++++++----------- .../net/rsprox/mcp/session/SessionManager.kt | 16 +-- 2 files changed, 53 insertions(+), 88 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 87840ce6..48f385fc 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -63,75 +63,6 @@ public sealed interface ClientState { ) : ClientState } -/** What can change the [ClientState] of a launched session. Several threads raise these. [reduce] decides. */ -public sealed interface SessionEvent { - /** A launch of the client began. */ - public data class Launched( - /** The launch that began. */ - val launch: Launch, - ) : SessionEvent - - /** The plugin of a launched client said hello. */ - public data class Hello( - /** The HTTP port of the launch that the client says it belongs to. */ - val httpPort: Int, - /** The link to the plugin that said hello. */ - val link: BridgeLink, - /** The process id of the client. */ - val pid: Long, - ) : SessionEvent - - /** The link to the plugin of the connected client closed. */ - public data class LinkClosed( - /** The link that closed. */ - val link: BridgeLink, - ) : SessionEvent - - /** The caller, or a failed launch, stops the client. */ - public data class Stop( - /** The reason the client is stopped. */ - val reason: String, - ) : SessionEvent - - /** The plugin of a launched client will not say hello any more. */ - public data class NeverConnected( - /** The launch whose client will not connect any more. */ - val launch: Launch, - /** The reason the client will not connect. */ - val reason: String, - ) : SessionEvent -} - -/** Get the state that follows [state]. Returns [state] itself when the event is stale or does not apply. */ -private fun reduce( - state: ClientState, - event: SessionEvent, -): ClientState = - when (event) { - is SessionEvent.Launched -> - if (state is ClientState.Stopped) ClientState.Launching(event.launch) else state - is SessionEvent.Hello -> - if (state is ClientState.Launching && state.launch.httpPort == event.httpPort) { - ClientState.Connected(state.launch, event.link, event.pid) - } else { - state - } - is SessionEvent.LinkClosed -> - if (state is ClientState.Connected && state.link === event.link) { - ClientState.Stopped("client exited") - } else { - state - } - is SessionEvent.Stop -> - if (state is ClientState.Stopped) state else ClientState.Stopped(event.reason) - is SessionEvent.NeverConnected -> - if (state is ClientState.Launching && state.launch == event.launch) { - ClientState.Stopped(event.reason) - } else { - state - } - } - /** One login of a session, as the proxy reports it. A value that is not known yet is left out of the JSON. */ @JsonInclude(JsonInclude.Include.NON_NULL) public data class LoginInfo( @@ -301,7 +232,7 @@ public class LaunchedSession internal constructor( /** The number of launches so far. */ private var generations = 0 - /** The state of the client, which only [apply] changes. */ + /** The state of the client, which only [transition] changes. */ @Volatile public var client: ClientState = ClientState.Stopped("not started") private set @@ -310,13 +241,14 @@ public class LaunchedSession internal constructor( internal fun nextGeneration(): Int = lock.withLock { ++generations } /** - * Apply the event and return the state that follows. + * Move to the state that [next] makes of the current one, and determine if that changed it. + * [next] returns the state it is given when what happened is stale or does not apply. * The only writer of [client]. Every real transition leaves a marker in the packet log. */ - internal fun apply(event: SessionEvent): ClientState = + private fun transition(next: (ClientState) -> ClientState): Boolean = lock.withLock { val before = client - val after = reduce(before, event) + val after = next(before) if (after !== before) { client = after @@ -324,17 +256,54 @@ public class LaunchedSession internal constructor( changed.signalAll() } - after + after !== before } - /** Apply the event and determine if it changed the state. */ - internal fun applied(event: SessionEvent): Boolean = + /** Register that [launch] of the client began, unless the session has a client already. */ + internal fun launched(launch: Launch) { + transition { if (it is ClientState.Stopped) ClientState.Launching(launch) else it } + } + + /** + * Connect the session to the plugin that said hello, when the session is launching and [httpPort] is + * the one of its launch. Determines if the session is connected through [link] afterwards. + */ + internal fun hello( + httpPort: Int, + link: BridgeLink, + pid: Long, + ): Boolean = lock.withLock { - val before = client + transition { + if (it is ClientState.Launching && it.launch.httpPort == httpPort) { + ClientState.Connected(it.launch, link, pid) + } else { + it + } + } - apply(event) !== before + (client as? ClientState.Connected)?.link === link } + /** Stop the session when [link] is the link of its connected client, and determine if that stopped it. */ + internal fun linkClosed(link: BridgeLink): Boolean = + transition { if (it is ClientState.Connected && it.link === link) ClientState.Stopped("client exited") else it } + + /** Stop the session for [reason], unless it is stopped already. */ + internal fun stop(reason: String) { + transition { if (it is ClientState.Stopped) it else ClientState.Stopped(reason) } + } + + /** + * Stop the session for [reason] when it still waits for the client of [launch], and determine if + * that stopped it. + */ + internal fun neverConnected( + launch: Launch, + reason: String, + ): Boolean = + transition { if (it is ClientState.Launching && it.launch == launch) ClientState.Stopped(reason) else it } + /** Block while the client is launching, for at most [waitMs]. Never throws on timeout. */ internal fun awaitConnected(waitMs: Long): ClientState = lock.withLock { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index babbe24d..4d0c71c6 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -109,7 +109,7 @@ public class SessionManager internal constructor( val state = resolved.client // Stopped first, so the close of the link below is not mistaken for the client exiting. - resolved.apply(SessionEvent.Stop("stopped by caller")) + resolved.stop("stopped by caller") when (state) { is ClientState.Stopped -> { @@ -247,7 +247,7 @@ public class SessionManager internal constructor( // Registered before the client is forked: its hello can arrive before the launch call returns. bridge.expect(launch.httpPort, listener(session, launch)) - session.apply(SessionEvent.Launched(launch)) + session.launched(launch) val reason = fork(session, reservation) @@ -258,7 +258,7 @@ public class SessionManager internal constructor( } release(launch) - session.apply(SessionEvent.Stop(reason)) + session.stop(reason) throw ToolError("session ${session.id} failed to launch: $reason") } @@ -346,7 +346,7 @@ public class SessionManager internal constructor( launch: Launch, reason: String, ) { - if (!session.applied(SessionEvent.NeverConnected(launch, reason))) return + if (!session.neverConnected(launch, reason)) return synchronized(launchLock) { release(launch) } } @@ -370,15 +370,11 @@ public class SessionManager internal constructor( override fun onHello( link: BridgeLink, pid: Long, - ): Boolean { - val state = session.apply(SessionEvent.Hello(launch.httpPort, link, pid)) - - return state is ClientState.Connected && state.link === link - } + ): Boolean = session.hello(launch.httpPort, link, pid) /** Stop the session when the link of its connected client closes, and release the proxy state. */ override fun onClosed(link: BridgeLink) { - if (!session.applied(SessionEvent.LinkClosed(link))) return + if (!session.linkClosed(link)) return // The client is gone, or cannot be driven any more. Either way the proxy still holds // its process handle and session monitor for the port. From 807246ea7dbdbb6780ef779b102403119904bd84 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:21:37 +0200 Subject: [PATCH 29/72] refactor(mcp): name the shared tool arguments once The `index`, `id`, `x` and `y` arguments of client_interact and client_camera, and the `widget` argument of client_click and client_interact, were written out in each tool. Each is now named once. The `string` and `stringArray` builders share the code that limits an argument to its allowed values. The schema of every tool is unchanged. Three descriptions change. client_interact says what an agent acts on, that it aims by identity, cancels a wrong click and turns the camera, and no longer walks through the mouse movement and the choice between a left and a right click. packets_read names `head` and `count`, and session_list names `generation`, `httpPort` and `pid`, which both returned already. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/server/Tools.kt | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 1ccf41c6..97b6d81a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -63,7 +63,9 @@ public fun tools(sessions: () -> SessionManager): List = "List every session, plus the target names. `kind` is `launched` for a client that " + "session_start launched, and `attached` for a client that was launched by hand from the " + "rsprox GUI, which only packets_read works on. Each session has `session`, `target`, " + - "`state`, `proxyPort` and `cursor`, the newest packet cursor. `state` is `stopped`, " + + "`state`, `proxyPort` and `cursor`, the newest packet cursor. A launched session with a " + + "client also has `generation`, the number of its launch, and `httpPort`, and `pid` once " + + "connected. `state` is `stopped`, " + "`launching` or `connected` when launched, and `attached` or `ended` when attached; " + "`reason` says why a session has no client. Once the client has logged in, `login` holds " + "`epoch` (the L number of its packets), `revision`, `world`, `host`, `localPlayerIndex`, " + @@ -84,7 +86,8 @@ public fun tools(sessions: () -> SessionManager): List = "To wait for a packet, pass `wait_ms`. The call then blocks until the first match. " + "Line 1 of the result is JSON: pass `next` as `after` on the following call; `dropped` counts " + "records that were evicted before they could be read; `timedOut` is true when a wait " + - "elapsed with no match. Each further line is one packet: " + + "elapsed with no match; `head` is the newest cursor of the log and `count` the number of " + + "packets that follow. Each further line is one packet: " + "` L T `, where C is client to server, S is server " + "to client and P is an rsprox marker (CLIENT_LAUNCHED, CLIENT_CONNECTED, CLIENT_ATTACHED, " + "CLIENT_EXITED, LOGIN, LOGOUT). Continuation lines of one packet are indented.", @@ -202,7 +205,7 @@ public fun tools(sessions: () -> SessionManager): List = "session" to SESSION, "x" to integer("Horizontal canvas position. Requires `y`.", 0), "y" to integer("Vertical canvas position. Requires `x`.", 0), - "widget" to string("Widget id as listed by client_widgets, such as \"558:7\" or \"558:7[3]\"."), + "widget" to WIDGET, "button" to string("Mouse button. Default left.", "left", "right"), ), sessions = sessions, @@ -247,14 +250,10 @@ public fun tools(sessions: () -> SessionManager): List = clientTool( name = "client_interact", description = - "Perform one option on one thing in the game as a player does, and confirm it in the " + - "packet log. The plugin moves the real mouse onto the target and lets the client build " + - "its menu. It left-clicks when the option looks like the default one. Otherwise, or when " + - "the client shows that a left click performs another option there, it opens the " + - "right-click menu and clicks the row. It cancels a click that the client resolves to " + - "anything else before the client sends it, and aims again. It turns the camera when the " + - "target is out of view. The server receives exactly what a player's click sends, mouse " + - "packets included. " + + "Perform one option on one thing in the game with a real mouse click, and confirm it in " + + "the packet log. It aims by identity, cancels a click that the client resolves to " + + "anything else and aims again, and turns the camera when the target is out of view. " + + "The server receives exactly what a player's click sends, mouse packets included. " + "Pick the target from client_entities. Name an `npc` or a `player` by `index`. Name an " + "`object` or a `ground_item` by `id` and its tile `x`,`y`. Name a `widget` by its " + "client_widgets id. For `dialog`, `option` is \"continue\", or the text or the 1-based " + @@ -278,11 +277,8 @@ public fun tools(sessions: () -> SessionManager): List = "session" to SESSION, "target" to string("Kind of thing to act on.", *TargetKind.names()), "option" to string("Option to perform, such as \"Talk-to\". Required unless `target` is tile."), - "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), - "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), - "x" to integer("World x of the tile of the object, ground item or tile.", 0), - "y" to integer("World y of the tile of the object, ground item or tile.", 0), - "widget" to string("Widget id as listed by client_widgets, such as \"558:7\" or \"558:7[3]\"."), + *TARGET_FIELDS, + "widget" to WIDGET, required = listOf("target"), ), sessions = sessions, @@ -308,10 +304,7 @@ public fun tools(sessions: () -> SessionManager): List = "Kind of target to face, named by the fields of client_interact.", *TargetKind.names { it.inWorld }, ), - "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), - "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), - "x" to integer("World x of the tile of the object, ground item or tile.", 0), - "y" to integer("World y of the tile of the object, ground item or tile.", 0), + *TARGET_FIELDS, ), sessions = sessions, timeoutMs = { INTERACTION_TIMEOUT_MS }, @@ -386,6 +379,18 @@ private const val CLIENT_TOOL_NOTE = /** The schema of the `session` argument that every tool but session_start and session_list takes. */ private val SESSION: ObjectNode = string("Session id, such as \"s1\". May be omitted while only one session exists.") +/** The schema of the `widget` argument of the tools that take a widget. */ +private val WIDGET: ObjectNode = string("Widget id as listed by client_widgets, such as \"558:7\" or \"558:7[3]\".") + +/** The arguments that name a target in the game world, for the tools that act on one. */ +private val TARGET_FIELDS: Array> = + arrayOf( + "index" to integer("Index of the NPC or player, as listed by client_entities.", 0), + "id" to integer("Id of the object or ground item, as listed by client_entities.", 0), + "x" to integer("World x of the tile of the object, ground item or tile.", 0), + "y" to integer("World y of the tile of the object, ground item or tile.", 0), + ) + /** Render a page as one line of JSON meta followed by one line per packet. */ private fun render(page: PacketPage): String { val meta = @@ -449,16 +454,7 @@ private fun integerArray(description: String): ObjectNode { private fun string( description: String, vararg allowed: String, -): ObjectNode { - val node = property("string", description) - - if (allowed.isNotEmpty()) { - val values = node.putArray("enum") - allowed.forEach(values::add) - } - - return node -} +): ObjectNode = property("string", description).limitedTo(allowed) /** Build the schema of an integer argument within the given bounds. */ private fun integer( @@ -480,14 +476,19 @@ private fun stringArray( vararg allowed: String, ): ObjectNode { val node = property("array", description) - val items = node.putObject("items").put("type", "string") + node.putObject("items").put("type", "string").limitedTo(allowed) + return node +} + +/** Limit the schema to the allowed values when any are given. */ +private fun ObjectNode.limitedTo(allowed: Array): ObjectNode { if (allowed.isNotEmpty()) { - val values = items.putArray("enum") + val values = putArray("enum") allowed.forEach(values::add) } - return node + return this } /** Build the schema of an argument with the given type and description. */ From df57de0a4abf31660add9f509479e6c96d2bfa2e Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:22:56 +0200 Subject: [PATCH 30/72] refactor(mcp): build the endpoint in one place The standalone server and the GUI extension each built the HTTP server with the same tool table, the same answer while rsprox is starting and the same version. Both now call one `endpoint` function. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/net/rsprox/mcp/McpExtension.kt | 18 ++++++++++++------ mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 9 +-------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt index e76bcc8d..03cb064d 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -71,12 +71,7 @@ internal fun serveOrNull( sessions: (opened: MutableList) -> SessionManager, ): McpHttpServer? { val built = AtomicReference() - val http = - McpHttpServer( - port, - tools { built.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, - System.getenv("APP_VERSION") ?: "dev", - ) + val http = endpoint(port, built) try { http.start() @@ -107,5 +102,16 @@ internal fun serveOrNull( return http } +/** Build the endpoint on [port], whose tools refuse every call until [sessions] holds the sessions. */ +internal fun endpoint( + port: Int, + sessions: AtomicReference, +): McpHttpServer = + McpHttpServer( + port, + tools { sessions.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, + System.getenv("APP_VERSION") ?: "dev", + ) + /** The logger of the endpoint. */ private val endpointLogger = InlineLogger() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 2ca8bd8a..0e21906b 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -13,9 +13,7 @@ import net.rsprox.mcp.bridge.BridgeJar import net.rsprox.mcp.bridge.Rendering import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.packets.UnfilteredFilterSetStore -import net.rsprox.mcp.server.McpHttpServer import net.rsprox.mcp.server.ToolError -import net.rsprox.mcp.server.tools import net.rsprox.mcp.session.ProxyServiceLauncher import net.rsprox.mcp.session.SessionManager import net.rsprox.proxy.ProxyService @@ -54,12 +52,7 @@ public class McpCommand : CliktCommand(name = "mcp") { override fun run() { Locale.setDefault(Locale.US) val sessions = AtomicReference() - val http = - McpHttpServer( - port, - tools { sessions.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, - System.getenv("APP_VERSION") ?: "dev", - ) + val http = endpoint(port, sessions) // Binding first makes a second instance exit here, before it touches the shared configuration. http.start() From 49663024e4cc4d32b73908b9a38c8149436f505e Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:22:56 +0200 Subject: [PATCH 31/72] refactor(mcp): make firstBound a method of its one caller `firstBound` took the allocation, the free check and the bind as three lambdas and a type parameter, and had one caller. It is now a private method of ProxyServiceLauncher that calls the three directly. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../mcp/session/ProxyServiceLauncher.kt | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index b91cdb2a..ec8fb4f4 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -32,7 +32,7 @@ internal class ProxyServiceLauncher( bridgeJar.installFor(target) // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. - val (port, proxyTarget) = firstBound(service::allocatePort, ::canBind) { bindHttpServer(it, target) } + val (port, proxyTarget) = firstBound(target) val forks = ForkWatch() return Reservation( @@ -49,6 +49,21 @@ internal class ProxyServiceLauncher( ) } + /** + * Get the first port of the proxy that is free and whose HTTP port can be bound, with the target that + * was bound for it. Throws when none of [BIND_ATTEMPTS] ports works. + */ + private fun firstBound(target: ProxyTargetConfig): Pair { + repeat(BIND_ATTEMPTS) { + val port = service.allocatePort() + val bound = if (canBind(port)) bindHttpServer(port, target) else null + + if (bound != null) return port to bound + } + + throw IllegalStateException("none of $BIND_ATTEMPTS proxy ports in a row could be bound with its HTTP port") + } + /** Kill the client on the proxy port, if any, and release the proxy state held for it. */ override fun kill(proxyPort: Int) { service.killAliveProcess(proxyPort) @@ -91,25 +106,6 @@ internal class ProxyServiceLauncher( /** The most proxy ports that one reservation tries. */ private const val BIND_ATTEMPTS = 64 -/** - * Get the first port from [allocate] that is free and that [bind] accepts, with what [bind] made for it. - * [bind] returns null for a port whose companion port is taken. Throws when none of [BIND_ATTEMPTS] ports works. - */ -private fun firstBound( - allocate: () -> Int, - isFree: (Int) -> Boolean, - bind: (Int) -> T?, -): Pair { - repeat(BIND_ATTEMPTS) { - val port = allocate() - val bound = if (isFree(port)) bind(port) else null - - if (bound != null) return port to bound - } - - throw IllegalStateException("none of $BIND_ATTEMPTS proxy ports in a row could be bound with its HTTP port") -} - /** * The processes this JVM forks from the moment the watch is made. Launches take turns, so the forks that * follow a reservation are those of its launch. From ee6a8ebd6fafa85663a2cb7578bc570b5726d8d7 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:22:56 +0200 Subject: [PATCH 32/72] refactor(mcp): build both pages of a packet read with one method `page` and `timedOutPage` differed only in the flag they set, so `page` now takes it. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/net/rsprox/mcp/packets/PacketLog.kt | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt index 655bf849..f1989d6c 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketLog.kt @@ -138,9 +138,9 @@ public class PacketLog( if (!scan.isAt(target)) continue - if (scan.matches.isNotEmpty() || waitMs <= 0) return scan.page() + if (scan.matches.isNotEmpty() || waitMs <= 0) return scan.page(timedOut = false) - target = awaitAppend(target, deadline) ?: return scan.timedOutPage() + target = awaitAppend(target, deadline) ?: return scan.page(timedOut = true) } } @@ -178,11 +178,8 @@ public class PacketLog( /** Determine if the scan has nothing left to do up to [target]: it reached it or holds the limit. */ fun isAt(target: Long): Boolean = isFull() || scanned >= target - /** Build the page of a read that ended without a wait elapsing. */ - fun page(): PacketPage = PacketPage(matches, Cursor(scanned), head(), dropped, timedOut = false) - - /** Build the page of a read whose wait elapsed with no match. */ - fun timedOutPage(): PacketPage = PacketPage(matches, Cursor(scanned), head(), dropped, timedOut = true) + /** Build the page of the read, whose wait elapsed with no match when [timedOut]. */ + fun page(timedOut: Boolean): PacketPage = PacketPage(matches, Cursor(scanned), head(), dropped, timedOut) /** Determine if the scan holds as many matches as the query asks for. */ private fun isFull(): Boolean = matches.size >= query.limit From 945734fa91dfcf37427dfc5c08fa4c68e9ca5324 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:22:57 +0200 Subject: [PATCH 33/72] refactor(mcp): drop two rendezvous fields and two results nobody reads The rendezvous file no longer holds `protocol` and `pid`. The plugin reads `port` and `token` from it and nothing else, and it states its protocol in its hello, which the hub still checks. `BridgeJar.installFor` and `LaunchedSession.awaitConnected` returned a path and a state that no caller used, so both return nothing now. Behaviour is otherwise unchanged. Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 2 -- mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt | 6 ++---- mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt | 5 ++--- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index ae626bae..97e2bee7 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -109,10 +109,8 @@ public class BridgeHub( val content = MAPPER .createObjectNode() - .put("protocol", PROTOCOL) .put("port", port) .put("token", token) - .put("pid", ProcessHandle.current().pid()) Files.createDirectories(rendezvous.parent) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt index 6f9075a1..3a0a31ea 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeJar.kt @@ -14,12 +14,12 @@ public class BridgeJar( * Install the jar unless the installed one already has the same content. A client that has the * old jar open keeps reading it, because the new one is moved into place rather than written over it. */ - public fun installFor(target: ProxyTargetConfig): Path { + public fun installFor(target: ProxyTargetConfig) { val dir = overrideDir ?: defaultDir(target) val installed = dir.resolve(FILE_NAME) val wanted = embedded() - if (Files.isRegularFile(installed) && Files.readAllBytes(installed).contentEquals(wanted)) return installed + if (Files.isRegularFile(installed) && Files.readAllBytes(installed).contentEquals(wanted)) return Files.createDirectories(dir) @@ -27,8 +27,6 @@ public class BridgeJar( val temp = Files.createTempFile(dir, FILE_NAME, ".tmp") Files.write(temp, wanted) Files.move(temp, installed, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) - - return installed } /** Get the sideload directory of the client of the target, in the home directory of the current user. */ diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 48f385fc..72001de3 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -305,15 +305,14 @@ public class LaunchedSession internal constructor( transition { if (it is ClientState.Launching && it.launch == launch) ClientState.Stopped(reason) else it } /** Block while the client is launching, for at most [waitMs]. Never throws on timeout. */ - internal fun awaitConnected(waitMs: Long): ClientState = + internal fun awaitConnected(waitMs: Long) { lock.withLock { var remaining = TimeUnit.MILLISECONDS.toNanos(waitMs) while (client is ClientState.Launching && remaining > 0) { remaining = changed.awaitNanos(remaining) } - - client } + } /** Get the link of the connected client. Throws a [ToolError] that says what to do about its absence. */ override fun link(tool: String): BridgeLink = From 0b0783f04308c9b0b93b4a96d348a82e9d3a31e5 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:23:33 +0200 Subject: [PATCH 34/72] refactor(mcp): skip the proxy ports in the standalone command Leaving the low proxy ports to a GUI only concerns the standalone server, yet the count went through the session builder that the GUI extension shares, which passed 0. McpCommand now skips the ports itself before it builds the sessions, and the builder and the launcher no longer take the count. Both servers allocate the ports they did before. Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt | 2 +- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 8 +++++--- .../kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt | 6 ------ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt index 03cb064d..acae817b 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -30,7 +30,7 @@ public class McpExtension : ProxyExtension { } serveOrNull(service.getMcpPort()) { opened -> - val manager = sessionManager(service, portSkip = 0, sideloadDir = null, Rendering.GPU, opened) + val manager = sessionManager(service, sideloadDir = null, Rendering.GPU, opened) service.addClientListener(AttachingListener(manager)) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 0e21906b..11de8fe8 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -94,7 +94,10 @@ public class McpCommand : CliktCommand(name = "mcp") { service.filterSetStore = UnfilteredFilterSetStore service.settingsStore = TapSettingSetStore - return sessionManager(service, portSkip.coerceAtLeast(1), sideloadDir, rendering, opened) + // Leaves the low ports to a GUI that shares the same configured range. + repeat(portSkip.coerceAtLeast(1)) { service.allocatePort() } + + return sessionManager(service, sideloadDir, rendering, opened) } private companion object { @@ -109,12 +112,11 @@ public class McpCommand : CliktCommand(name = "mcp") { */ internal fun sessionManager( service: ProxyService, - portSkip: Int, sideloadDir: Path?, rendering: Rendering, opened: MutableList, ): SessionManager { - val launcher = ProxyServiceLauncher(service, portSkip, BridgeJar(sideloadDir)) + val launcher = ProxyServiceLauncher(service, BridgeJar(sideloadDir)) // The plugin reads the same path in McpBridgePlugin.java. val rendezvous = Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json") diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index ec8fb4f4..1cfac88f 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -15,15 +15,9 @@ import java.util.stream.Collectors internal class ProxyServiceLauncher( /** The proxy that launches the clients. */ private val service: ProxyService, - portSkip: Int, /** The installer of the bridge plugin. */ private val bridgeJar: BridgeJar, ) : ClientLauncher { - init { - // Leaves the low ports to a GUI that shares the same configured range. - repeat(portSkip) { service.allocatePort() } - } - /** Get the proxy targets the proxy is configured with. */ override fun targets(): List = service.proxyTargets From 8fcf779cbdc68c08941a4e096ad8d5be10ff8a79 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:23:59 +0200 Subject: [PATCH 35/72] fix(mcp): report the capture file from the moment of the login LoginTap published `captureFile` from the cache callback, as a stand-in for the moment the proxy has set the recording up. The name of the file is known when the login is, so LoginInfo now takes it when it is built and the cache callback only keeps the cache. session_list therefore shows `captureFile` in the same answer that first shows `login`, where it used to appear a moment later, once the proxy had handed the cache over. A login of a target that is not recorded still has none. The test of the login facts loses the step that sat between the two. Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt | 6 ++---- mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt | 5 +++-- .../kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt | 3 --- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt index eabf3822..d6cb61c0 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/packets/PacketTap.kt @@ -72,11 +72,9 @@ private class LoginTap( /** Get this tap, which is the monitor of its one login already. */ override fun forSession(header: BinaryHeader): SessionMonitor = this - /** Keep the cache for the formatter, and register the file that the login is recorded to. */ + /** Keep the cache for the formatter. */ override fun onCacheUpdate(cacheProvider: CacheProvider) { cache = cacheProvider - - logins.update(epoch) { it.copy(captureFile = captureFile) } } /** Keep the direction of the next packet. The first packet also registers that this login is being transcribed. */ @@ -118,7 +116,7 @@ private class LoginTap( /** Register the login and mark it in the log. */ override fun onLogin(header: BinaryHeader) { - logins.login(LoginInfo.of(epoch, header)) + logins.login(LoginInfo.of(epoch, header, captureFile)) log.append(epoch, 0, Origin.PROXY, "LOGIN", "revision=${header.revision} world=${header.worldId}") } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 72001de3..60fd6d09 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -80,7 +80,7 @@ public data class LoginInfo( val connectedAt: String, /** * The file the login is recorded to, relative to the `binary` directory of rsprox. - * Null for a target that is not recorded, and until the proxy has set the recording up. + * Null for a target that is not recorded. */ val captureFile: String?, /** The display name of the player, or null until the proxy reports it. */ @@ -100,6 +100,7 @@ public data class LoginInfo( internal fun of( epoch: Int, header: BinaryHeader, + captureFile: String?, ): LoginInfo = LoginInfo( epoch = epoch, @@ -108,7 +109,7 @@ public data class LoginInfo( host = header.worldHost, localPlayerIndex = header.localPlayerIndex, connectedAt = Instant.ofEpochMilli(header.timestamp).toString(), - captureFile = null, + captureFile = captureFile, name = null, online = true, transcribing = false, diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt index 31c210db..20025e06 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -168,9 +168,6 @@ class AttachedSessionTest { val login = client.forSession(header) login.onLogin(header) - assertEquals(listOf("""$attached,$accepted,"online":true,"transcribing":false},"cursor":2}"""), sessions()) - - login.onCacheUpdate { error("the test formats no packet that needs the cache") } val recorded = """$accepted,"captureFile":"Local/${header.fileName()}"""" assertEquals(listOf("""$attached,$recorded,"online":true,"transcribing":false},"cursor":2}"""), sessions()) From 235879f921dced20e1b3ebc48b1ed898f42ee5ee Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:20:09 +0200 Subject: [PATCH 36/72] refactor(mcp-bridge): use streams and a comparator chain in place of loops The menu entries and the offered options are now mapped with streams, the order of an entity listing is a comparator chain, and the GPU plugin is found with a stream. The stream keeps the last plugin with the name, as the loop did. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../java/net/rsprox/mcpbridge/Entities.java | 24 +++++++------------ .../net/rsprox/mcpbridge/McpBridgePlugin.java | 11 ++++----- .../main/java/net/rsprox/mcpbridge/Offer.java | 10 ++------ .../java/net/rsprox/mcpbridge/Scenes.java | 15 +++++------- 4 files changed, 21 insertions(+), 39 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java index 1b488a42..7819a3e4 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Entities.java @@ -4,7 +4,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.util.ArrayList; -import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; @@ -71,7 +71,13 @@ private static Set kinds(JsonObject args) { } /** One entity that passed the filters. */ - private static final class Entry implements Comparable { + private static final class Entry { + /** The order of a listing: the nearest first, then by index or id, then by tile, so that it is stable. */ + static final Comparator ORDER = Comparator.comparingInt(entry -> entry.distance) + .thenComparingInt(entry -> entry.key) + .thenComparingInt(entry -> entry.x) + .thenComparingInt(entry -> entry.y); + /** The kind of the entity. */ final String kind; @@ -99,18 +105,6 @@ private static final class Entry implements Comparable { this.y = y; this.described = described; } - - /** Order the nearest first, then by index or id, then by tile, so a listing is stable. */ - @Override - public int compareTo(Entry other) { - if (distance != other.distance) return Integer.compare(distance, other.distance); - - if (key != other.key) return Integer.compare(key, other.key); - - if (x != other.x) return Integer.compare(x, other.x); - - return Integer.compare(y, other.y); - } } /** Collects the entities around the local player on its plane, in the top-level world view. */ @@ -264,7 +258,7 @@ private int worldY(Point scene) { /** Build the result from the nearest entities, up to the limit, with one list per kind. */ JsonObject result(int limit) { - Collections.sort(found); + found.sort(Entry.ORDER); JsonObject out = new JsonObject(); JsonObject at = new JsonObject(); at.addProperty("x", origin.getX()); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index 674018e2..1355ab75 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -94,13 +94,10 @@ private void onConnected(BridgeConnection connection) { * of disabling it, leaves the profile untouched for clients that share it. */ private void stopGpuPlugin() { - Plugin gpu = null; - - for (Plugin plugin : pluginManager.getPlugins()) { - if (plugin.getClass().getSimpleName().equals(GPU_PLUGIN)) { - gpu = plugin; - } - } + Plugin gpu = pluginManager.getPlugins().stream() + .filter(plugin -> plugin.getClass().getSimpleName().equals(GPU_PLUGIN)) + .reduce((first, last) -> last) + .orElse(null); if (gpu == null || !pluginManager.isPluginEnabled(gpu)) return; diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java index 6ab7e1fd..a05f7ddd 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Offer.java @@ -4,9 +4,9 @@ import java.awt.Point; import java.awt.Rectangle; import java.awt.Shape; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; import net.runelite.api.Client; import net.runelite.api.Menu; import net.runelite.api.MenuAction; @@ -107,13 +107,7 @@ private static boolean isUnder(Client client, Target target, Point aim) { /** Write each entry as its option and its target with the colour tags stripped, in the given order. */ private static List words(MenuEntry[] entries) { - List out = new ArrayList<>(); - - for (MenuEntry entry : entries) { - out.add(words(entry)); - } - - return out; + return Arrays.stream(entries).map(entry -> words(entry)).collect(Collectors.toList()); } /** Determine if the matching entry is the last of the array, which is the one a left click usually performs. */ diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java index a9dffa45..3ce5e142 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java @@ -3,7 +3,9 @@ import java.awt.Polygon; import java.awt.Rectangle; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; import net.runelite.api.Client; import net.runelite.api.GameObject; import net.runelite.api.ObjectComposition; @@ -77,15 +79,10 @@ static ObjectComposition shown(Client client, int id) { /** Get the names of the options that are offered, in order and without their colour tags. */ static List offered(String[] options) { - List out = new ArrayList<>(); - - for (String option : options == null ? new String[0] : options) { - String name = Offer.untagged(option); - - if (!name.isEmpty()) out.add(name); - } - - return out; + return Arrays.stream(options == null ? new String[0] : options) + .map(Offer::untagged) + .filter(name -> !name.isEmpty()) + .collect(Collectors.toList()); } /** Get the part of the canvas that shows the 3D scene. */ From 4c8585cddf60e3b96521943557357630da04ddb6 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:20:47 +0200 Subject: [PATCH 37/72] refactor(mcp-bridge): merge the NPC and player targets into one class NpcTarget and PlayerTarget had the same shape, matches, tile and find, and differed in the list they looked in, their menu actions and their words. ActorTarget holds both kinds and is made through ActorTarget.npc and ActorTarget.player. The labels, the descriptions and the matching of menu entries are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcpbridge/ActorTarget.java | 120 ++++++++++++++++++ .../java/net/rsprox/mcpbridge/NpcTarget.java | 85 ------------- .../net/rsprox/mcpbridge/PlayerTarget.java | 82 ------------ .../java/net/rsprox/mcpbridge/Targets.java | 4 +- 4 files changed, 122 insertions(+), 169 deletions(-) create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ActorTarget.java delete mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java delete mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ActorTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ActorTarget.java new file mode 100644 index 00000000..cbf3a9d7 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ActorTarget.java @@ -0,0 +1,120 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.awt.Shape; +import java.util.EnumSet; +import java.util.Set; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.WorldView; +import net.runelite.api.coords.WorldPoint; + +/** + * An NPC or another player, named by its index in the client's list of its kind. Its menu entries + * carry that index as the identifier. + */ +final class ActorTarget extends Target { + /** The menu actions that perform an option on an NPC. */ + private static final Set NPC_FAMILY = EnumSet.of( + MenuAction.NPC_FIRST_OPTION, + MenuAction.NPC_SECOND_OPTION, + MenuAction.NPC_THIRD_OPTION, + MenuAction.NPC_FOURTH_OPTION, + MenuAction.NPC_FIFTH_OPTION, + MenuAction.EXAMINE_NPC); + + /** The menu actions that perform an option on a player. */ + private static final Set PLAYER_FAMILY = EnumSet.of( + MenuAction.PLAYER_FIRST_OPTION, + MenuAction.PLAYER_SECOND_OPTION, + MenuAction.PLAYER_THIRD_OPTION, + MenuAction.PLAYER_FOURTH_OPTION, + MenuAction.PLAYER_FIFTH_OPTION, + MenuAction.PLAYER_SIXTH_OPTION, + MenuAction.PLAYER_SEVENTH_OPTION, + MenuAction.PLAYER_EIGHTH_OPTION); + + /** Whether the actor is an NPC, and another player otherwise. */ + private final boolean npc; + + /** The index of the actor. */ + private final int index; + + /** The id of the NPC when it was found, which is unused for a player. */ + private final int id; + + /** The name of the actor when it was found. */ + private final String name; + + /** Create the target for the actor of the kind with the index, as found with the id and name. */ + private ActorTarget(boolean npc, int index, int id, String name, String option) { + super(option); + this.npc = npc; + this.index = index; + this.id = id; + this.name = name; + } + + /** Create the target for the NPC with the index, as found with the id and name. */ + static ActorTarget npc(int index, int id, String name, String option) { + return new ActorTarget(true, index, id, name, option); + } + + /** Create the target for the player with the index, as found with the name. */ + static ActorTarget player(int index, String name, String option) { + return new ActorTarget(false, index, -1, name, option); + } + + /** Get the words that name the actor. */ + @Override + String label() { + return (npc ? "npc " : "player ") + index + " (" + name + ")"; + } + + /** Describe the actor with its index, its id when it is an NPC, and its name. */ + @Override + JsonObject describe() { + JsonObject out = npc ? described("OPNPC", "npc") : described("OPPLAYER", "player"); + out.addProperty("index", index); + + if (npc) out.addProperty("id", id); + + out.addProperty("name", name); + out.addProperty("option", option); + + return out; + } + + /** Get the convex hull of the actor's model, or null while the client does not show it. */ + @Override + Shape shape(Client client) { + Actor actor = find(client); + + return actor == null ? null : actor.getConvexHull(); + } + + /** Determine if the entry is this option on the actor of this kind with this index. */ + @Override + boolean matches(MenuEntry entry, Client client) { + Set family = npc ? NPC_FAMILY : PLAYER_FAMILY; + + return family.contains(entry.getType()) && entry.getIdentifier() == index && sameOption(entry); + } + + /** Get the tile the actor stands on, or null while the client does not have it. */ + @Override + WorldPoint tile(Client client) { + Actor actor = find(client); + + return actor == null ? null : actor.getWorldLocation(); + } + + /** Find the actor in the client's list of its kind, or null while it is not there. */ + private Actor find(Client client) { + WorldView view = client.getTopLevelWorldView(); + + return npc ? view.npcs().byIndex(index) : view.players().byIndex(index); + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java deleted file mode 100644 index e4992f4e..00000000 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/NpcTarget.java +++ /dev/null @@ -1,85 +0,0 @@ -package net.rsprox.mcpbridge; - -import com.google.gson.JsonObject; -import java.awt.Shape; -import java.util.EnumSet; -import java.util.Set; -import net.runelite.api.Client; -import net.runelite.api.MenuAction; -import net.runelite.api.MenuEntry; -import net.runelite.api.NPC; -import net.runelite.api.coords.WorldPoint; - -/** An NPC, named by its index in the client's list. Its menu entries carry that index as the identifier. */ -final class NpcTarget extends Target { - /** The menu actions that perform an option on an NPC. */ - private static final Set FAMILY = EnumSet.of( - MenuAction.NPC_FIRST_OPTION, - MenuAction.NPC_SECOND_OPTION, - MenuAction.NPC_THIRD_OPTION, - MenuAction.NPC_FOURTH_OPTION, - MenuAction.NPC_FIFTH_OPTION, - MenuAction.EXAMINE_NPC); - - /** The index of the NPC. */ - private final int index; - - /** The id of the NPC when it was found. */ - private final int id; - - /** The name of the NPC when it was found. */ - private final String name; - - /** Create the target for the NPC with the index, as found with the id and name. */ - NpcTarget(int index, int id, String name, String option) { - super(option); - this.index = index; - this.id = id; - this.name = name; - } - - /** Get the words that name the NPC. */ - @Override - String label() { - return "npc " + index + " (" + name + ")"; - } - - /** Describe the NPC with its index, id and name. */ - @Override - JsonObject describe() { - JsonObject out = described("OPNPC", "npc"); - out.addProperty("index", index); - out.addProperty("id", id); - out.addProperty("name", name); - out.addProperty("option", option); - - return out; - } - - /** Get the convex hull of the NPC's model, or null while the client does not show it. */ - @Override - Shape shape(Client client) { - NPC npc = find(client); - - return npc == null ? null : npc.getConvexHull(); - } - - /** Determine if the entry is this option on the NPC with this index. */ - @Override - boolean matches(MenuEntry entry, Client client) { - return FAMILY.contains(entry.getType()) && entry.getIdentifier() == index && sameOption(entry); - } - - /** Get the tile the NPC stands on, or null while the client does not have it. */ - @Override - WorldPoint tile(Client client) { - NPC npc = find(client); - - return npc == null ? null : npc.getWorldLocation(); - } - - /** Find the NPC in the client's list, or null while it is not there. */ - private NPC find(Client client) { - return client.getTopLevelWorldView().npcs().byIndex(index); - } -} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java deleted file mode 100644 index eb9395b1..00000000 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/PlayerTarget.java +++ /dev/null @@ -1,82 +0,0 @@ -package net.rsprox.mcpbridge; - -import com.google.gson.JsonObject; -import java.awt.Shape; -import java.util.EnumSet; -import java.util.Set; -import net.runelite.api.Client; -import net.runelite.api.MenuAction; -import net.runelite.api.MenuEntry; -import net.runelite.api.Player; -import net.runelite.api.coords.WorldPoint; - -/** Another player, named by its index in the client's list. Its menu entries carry that index as the identifier. */ -final class PlayerTarget extends Target { - /** The menu actions that perform an option on a player. */ - private static final Set FAMILY = EnumSet.of( - MenuAction.PLAYER_FIRST_OPTION, - MenuAction.PLAYER_SECOND_OPTION, - MenuAction.PLAYER_THIRD_OPTION, - MenuAction.PLAYER_FOURTH_OPTION, - MenuAction.PLAYER_FIFTH_OPTION, - MenuAction.PLAYER_SIXTH_OPTION, - MenuAction.PLAYER_SEVENTH_OPTION, - MenuAction.PLAYER_EIGHTH_OPTION); - - /** The index of the player. */ - private final int index; - - /** The name of the player when it was found. */ - private final String name; - - /** Create the target for the player with the index, as found with the name. */ - PlayerTarget(int index, String name, String option) { - super(option); - this.index = index; - this.name = name; - } - - /** Get the words that name the player. */ - @Override - String label() { - return "player " + index + " (" + name + ")"; - } - - /** Describe the player with its index and name. */ - @Override - JsonObject describe() { - JsonObject out = described("OPPLAYER", "player"); - out.addProperty("index", index); - out.addProperty("name", name); - out.addProperty("option", option); - - return out; - } - - /** Get the convex hull of the player's model, or null while the client does not show it. */ - @Override - Shape shape(Client client) { - Player player = find(client); - - return player == null ? null : player.getConvexHull(); - } - - /** Determine if the entry is this option on the player with this index. */ - @Override - boolean matches(MenuEntry entry, Client client) { - return FAMILY.contains(entry.getType()) && entry.getIdentifier() == index && sameOption(entry); - } - - /** Get the tile the player stands on, or null while the client does not have it. */ - @Override - WorldPoint tile(Client client) { - Player player = find(client); - - return player == null ? null : player.getWorldLocation(); - } - - /** Find the player in the client's list, or null while it is not there. */ - private Player find(Client client) { - return client.getTopLevelWorldView().players().byIndex(index); - } -} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java index 3d7dbcc2..f290acd8 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java @@ -174,7 +174,7 @@ private static Candidate npc(Client client, JsonObject args, String option) thro String name = Ops.nullToEmpty(composition.getName()); - return new Candidate(new NpcTarget(index, npc.getId(), name, option), withExamine(composition.getActions())); + return new Candidate(ActorTarget.npc(index, npc.getId(), name, option), withExamine(composition.getActions())); } /** Find the player with the index in the client's list of players. */ @@ -185,7 +185,7 @@ private static Candidate player(Client client, JsonObject args, String option) t if (player == null) throw notFound("no player with index " + index + " is in the client's view"); String[] options = Arrays.copyOf(client.getPlayerOptions(), PLAYER_OPTIONS); - PlayerTarget target = new PlayerTarget(index, Ops.nullToEmpty(player.getName()), option); + ActorTarget target = ActorTarget.player(index, Ops.nullToEmpty(player.getName()), option); return new Candidate(target, Scenes.offered(options)); } From 1646aee9fcdc0cca69990a026b1ca697de2f1fc5 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:20:47 +0200 Subject: [PATCH 38/72] refactor(mcp-bridge): check the scene tile of a menu entry in one place The object and the ground item targets each compared the parameters of a menu entry with the scene tile of their world tile. Scenes.isOnTile now makes that comparison for both. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../main/java/net/rsprox/mcpbridge/GroundItemTarget.java | 5 +---- .../src/main/java/net/rsprox/mcpbridge/ObjectTarget.java | 6 +----- .../src/main/java/net/rsprox/mcpbridge/Scenes.java | 9 +++++++++ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java index dd5351f7..3abec682 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GroundItemTarget.java @@ -80,10 +80,7 @@ Shape shape(Client client) { /** Determine if the entry is this option on the item with this id on this tile. */ @Override boolean matches(MenuEntry entry, Client client) { - WorldView view = client.getTopLevelWorldView(); - int sceneX = at.getX() - view.getBaseX(); - int sceneY = at.getY() - view.getBaseY(); - boolean sameTile = entry.getParam0() == sceneX && entry.getParam1() == sceneY; + boolean sameTile = Scenes.isOnTile(entry, client, at); return FAMILY.contains(entry.getType()) && entry.getIdentifier() == id && sameTile && sameOption(entry); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java index 89aaf2bd..9101cc9f 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ObjectTarget.java @@ -11,7 +11,6 @@ import net.runelite.api.Point; import net.runelite.api.Tile; import net.runelite.api.TileObject; -import net.runelite.api.WorldView; import net.runelite.api.coords.WorldPoint; /** @@ -84,10 +83,7 @@ Shape shape(Client client) { /** Determine if the entry is this option on the object with this id at this tile. */ @Override boolean matches(MenuEntry entry, Client client) { - WorldView view = client.getTopLevelWorldView(); - int sceneX = origin.getX() - view.getBaseX(); - int sceneY = origin.getY() - view.getBaseY(); - boolean sameTile = entry.getParam0() == sceneX && entry.getParam1() == sceneY; + boolean sameTile = Scenes.isOnTile(entry, client, origin); return FAMILY.contains(entry.getType()) && entry.getIdentifier() == id && sameTile && sameOption(entry); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java index 3ce5e142..4663be97 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Scenes.java @@ -8,6 +8,7 @@ import java.util.stream.Collectors; import net.runelite.api.Client; import net.runelite.api.GameObject; +import net.runelite.api.MenuEntry; import net.runelite.api.ObjectComposition; import net.runelite.api.Perspective; import net.runelite.api.Point; @@ -15,6 +16,7 @@ import net.runelite.api.TileObject; import net.runelite.api.WorldView; import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; /** * Reads of the top-level world view that the entity listing and the interactions share. World @@ -85,6 +87,13 @@ static List offered(String[] options) { .collect(Collectors.toList()); } + /** Determine if the parameters of the menu entry name the scene tile of the world tile, as an object's do. */ + static boolean isOnTile(MenuEntry entry, Client client, WorldPoint at) { + WorldView view = client.getTopLevelWorldView(); + + return entry.getParam0() == at.getX() - view.getBaseX() && entry.getParam1() == at.getY() - view.getBaseY(); + } + /** Get the part of the canvas that shows the 3D scene. */ static Rectangle viewport(Client client) { return new Rectangle( From 8f3c9706a894aac3d75cf390a0856763bdd213c5 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:21:14 +0200 Subject: [PATCH 39/72] refactor(mcp-bridge): drop three helpers that repeat another Interaction.point built the same array as Ops.ints, which is now visible to the package. The covered aim and the target that is off screen shared the steps that extend the deadline and turn the camera, which turnToward now holds. ClickGuard.performed only wrapped Verdict.performed. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../java/net/rsprox/mcpbridge/ClickGuard.java | 9 ++---- .../net/rsprox/mcpbridge/Interaction.java | 32 +++++++------------ .../main/java/net/rsprox/mcpbridge/Ops.java | 2 +- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java index 3591e329..dac743d9 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/ClickGuard.java @@ -112,18 +112,15 @@ private Verdict judge(Client client, MenuOptionClicked event) { if (event.isConsumed()) return Verdict.missed("another plugin consumed the click on " + words); - if (target.matches(event.getMenuEntry(), client)) return performed(client, words); + if (target.matches(event.getMenuEntry(), client)) { + return Verdict.performed("the client performed " + words, client.getTickCount()); + } event.consume(); return cancelled(client, words); } - /** Build the verdict of the intended click, which the client performs on its current tick. */ - private static Verdict performed(Client client, String words) { - return Verdict.performed("the client performed " + words, client.getTickCount()); - } - /** * Build the verdict of a cancelled click. When the menu still holds the intended entry, the client * preferred another entry to it at this point, which a click through the open menu gets around. diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java index 217de0c5..56bb0a67 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Interaction.java @@ -311,23 +311,22 @@ private Verdict covered(Offer offer, String at) throws BridgeException { if (covered < COVERED_BEFORE_TURN) return verdict; - WorldPoint tile = game.onClientThread(target::tile); - if (tile == null) return verdict; - - deadline = Math.max(deadline, began + CAMERA_CYCLES); - - return Verdict.missed(BLOCKED + at + "; " + game.onClientThread(client -> camera.toward(client, tile))); + return turnToward(game.onClientThread(target::tile), BLOCKED + at); } - /** - * Turn the camera toward a target that has no clickable shape on screen, and allow the longer - * deadline that turning needs. - */ + /** Turn the camera toward a target that has no clickable shape on screen. */ private Verdict bringIntoView() throws BridgeException { WorldPoint tile = game.onClientThread(target::tile); - String words = "no part of " + target.label() + " is on screen"; game.awaitCycles(1); + return turnToward(tile, "no part of " + target.label() + " is on screen"); + } + + /** + * Build the verdict of an attempt that missed with the words, after turning the camera toward the + * tile and allowing the longer deadline that turning needs, unless the target is not in the world. + */ + private Verdict turnToward(WorldPoint tile, String words) throws BridgeException { if (tile == null) return Verdict.missed(words); deadline = Math.max(deadline, began + CAMERA_CYCLES); @@ -347,7 +346,7 @@ private JsonObject result(Verdict verdict) { JsonObject out = target.describe(); out.addProperty("attempts", attempts); out.addProperty("tick", verdict.tick); - out.add("pressed", point(pressed)); + out.add("pressed", Ops.ints(pressed.x, pressed.y)); JsonArray words = new JsonArray(); tried.forEach(words::add); out.add("tried", words); @@ -357,15 +356,6 @@ private JsonObject result(Verdict verdict) { return out; } - /** Build the JSON array of the x and the y of the canvas point. */ - private static JsonArray point(Point point) { - JsonArray out = new JsonArray(); - out.add(point.x); - out.add(point.y); - - return out; - } - /** Build the failure for a deadline that passed, with what happened at the last attempt. */ private BridgeException expired() { String last = reads > 0 && offeredMore == 0 ? BLOCKED : tried.peekLast(); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 94a000d6..6daaf503 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -571,7 +571,7 @@ static String ref(Widget widget) { } /** Build a JSON array of the values. */ - private static JsonArray ints(int... values) { + static JsonArray ints(int... values) { JsonArray out = new JsonArray(); for (int value : values) { From be7513fe671d362033ba13dc8ea80b75ba5937c4 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:21:26 +0200 Subject: [PATCH 40/72] refactor(mcp-bridge): keep the software rendering flag as a boolean The Rendering enum carried the one bit that the welcome of rsprox sends as softwareRendering. The connection now holds that boolean, and the enum, its fromWire and the getter are gone. The welcome is read as before and the GPU plugin is stopped in the same case. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcpbridge/BridgeConnection.java | 16 +++++----------- .../net/rsprox/mcpbridge/McpBridgePlugin.java | 2 +- .../java/net/rsprox/mcpbridge/Rendering.java | 15 --------------- 3 files changed, 6 insertions(+), 27 deletions(-) delete mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java index 9458a050..9c4c562b 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -61,8 +61,8 @@ final class BridgeConnection implements AutoCloseable { /** The ops that requests run. */ private final Ops ops; - /** The rendering that rsprox asked for in its welcome. */ - private final Rendering rendering; + /** Whether rsprox asked in its welcome for a client without the GPU plugin. */ + final boolean softwareRendering; /** Whether the connection has been closed. */ private final AtomicBoolean closed = new AtomicBoolean(); @@ -72,8 +72,8 @@ final class BridgeConnection implements AutoCloseable { /** Create a connection over a socket that rsprox has welcomed. */ private BridgeConnection( - Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops, Rendering rendering) { - this.rendering = rendering; + Socket socket, BufferedReader reader, BufferedWriter writer, Gson gson, Ops ops, boolean softwareRendering) { + this.softwareRendering = softwareRendering; this.socket = socket; this.reader = reader; this.writer = writer; @@ -128,8 +128,7 @@ static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops socket.setSoTimeout(0); boolean software = answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); - Rendering rendering = Rendering.fromWire(software); - BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, rendering); + BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, software); connection.start(); log.info("rsprox MCP bridge connected as session {}", answer.get("session")); @@ -147,11 +146,6 @@ static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops } } - /** Get the rendering, which is {@link Rendering#SOFTWARE} when rsprox asked for a client without the GPU plugin. */ - Rendering rendering() { - return rendering; - } - /** * Get the port in the jav_config argument of a client's command line, which is how rsprox tells its * clients apart. Empty when rsprox did not launch the client. diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index 1355ab75..abd78d8f 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -83,7 +83,7 @@ protected void startUp() { /** Stop the GPU plugin when rsprox asked for software rendering. Runs on the thread of the dial. */ private void onConnected(BridgeConnection connection) { - if (connection.rendering() != Rendering.SOFTWARE) return; + if (!connection.softwareRendering) return; stopGpuPlugin(); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java deleted file mode 100644 index f25b1c6e..00000000 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Rendering.java +++ /dev/null @@ -1,15 +0,0 @@ -package net.rsprox.mcpbridge; - -/** How rsprox wants this client to draw its frames. */ -enum Rendering { - /** Frames drawn by the GPU plugin, when the client's profile enables it. */ - GPU, - - /** Frames drawn without the GPU plugin, which the bridge stops. */ - SOFTWARE; - - /** Get the rendering that the {@code softwareRendering} flag of a welcome asks for. */ - static Rendering fromWire(boolean softwareRendering) { - return softwareRendering ? SOFTWARE : GPU; - } -} From 70fca29f045c286d1b1291f95b3662867b370d8d Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:21:44 +0200 Subject: [PATCH 41/72] refactor(mcp-bridge): use the JDK's interfaces for two callbacks Ops.VarReader had the shape of IntFunction and GameAccess.ClientEvent that of BiConsumer, so both interfaces are gone. Op and ClientCall throw a checked exception and stay. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../main/java/net/rsprox/mcpbridge/GameAccess.java | 13 ++++--------- .../src/main/java/net/rsprox/mcpbridge/Ops.java | 12 ++++-------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java index 1a2f13bf..373dc1f3 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/GameAccess.java @@ -10,6 +10,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; import java.util.function.Consumer; import javax.swing.SwingUtilities; import net.runelite.api.Client; @@ -62,12 +63,6 @@ interface ClientCall { T call(Client client) throws BridgeException; } - /** A handler of one type of event that the client posts. */ - interface ClientEvent { - /** Handle an event that the client posted, on the thread it posted it from. */ - void on(Client client, T event); - } - /** A registration for events, which lasts until it is closed. */ interface Subscription extends AutoCloseable { /** Stop receiving the events. Idempotent. */ @@ -101,9 +96,9 @@ void input(Consumer body) throws BridgeException { } } - /** Receive every event of the type until the subscription is closed. */ - Subscription subscribe(Class type, ClientEvent handler) { - EventBus.Subscriber subscriber = eventBus.register(type, event -> handler.on(client, event), PRIORITY); + /** Receive every event of the type, on the thread the client posts it from, until the subscription is closed. */ + Subscription subscribe(Class type, BiConsumer handler) { + EventBus.Subscriber subscriber = eventBus.register(type, event -> handler.accept(client, event), PRIORITY); return () -> eventBus.unregister(subscriber); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 6daaf503..9a788202 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -21,6 +21,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.function.IntFunction; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -316,19 +317,14 @@ private JsonElement vars(JsonObject args) throws BridgeException { }); } - /** A read of one kind of client variable. */ - private interface VarReader { - /** Read the variable with the given id. */ - JsonElement read(int id); - } - /** Read each id through the reader, keyed by id. Throws {@code not_found} for an id the client does not have. */ - private static JsonObject readVars(String kind, int[] ids, VarReader reader) throws BridgeException { + private static JsonObject readVars(String kind, int[] ids, IntFunction reader) + throws BridgeException { JsonObject out = new JsonObject(); for (int id : ids) { try { - out.add(Integer.toString(id), reader.read(id)); + out.add(Integer.toString(id), reader.apply(id)); } catch (RuntimeException e) { // The client indexes its tables with the id and throws for one that does not exist. throw new BridgeException("not_found", kind + " " + id + " could not be read: " + e); From f99d005c9b0527783ff688f13a5e5229cf2998c2 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:21:52 +0200 Subject: [PATCH 42/72] refactor(mcp-bridge): build the three login failures where they are thrown neverLoaded, notOnLoginScreen and neverLoggedIn each had one caller, which now builds the failure itself. The codes and the messages are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../main/java/net/rsprox/mcpbridge/Ops.java | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 9a788202..bc995137 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -484,25 +484,24 @@ private void awaitLoaded(long deadline) throws BridgeException { GameState state = game.onClientThread(Client::getGameState); if (state != GameState.STARTING && state != GameState.UNKNOWN) return; - if (System.currentTimeMillis() >= deadline) throw neverLoaded(state); + if (System.currentTimeMillis() >= deadline) { + String reached = "the client did not reach the login screen; gameState is " + state + ". "; + String hint = "A client that cannot reach the game server stays in this state."; + + throw new BridgeException("timeout", reached + hint); + } pollAgain("the login screen"); } } - /** Build the timeout for a client that is still in the given state when the wait for the login screen ends. */ - private static BridgeException neverLoaded(GameState state) { - String reached = "the client did not reach the login screen; gameState is " + state + ". "; - String hint = "A client that cannot reach the game server stays in this state."; - - return new BridgeException("timeout", reached + hint); - } - /** Set the credentials and start the login. Throws {@code wrong_state} unless the client is on the login screen. */ private void submitCredentials(String username, String password) throws BridgeException { game.onClientThread(client -> { GameState state = client.getGameState(); - if (state != GameState.LOGIN_SCREEN) throw notOnLoginScreen(state); + if (state != GameState.LOGIN_SCREEN) { + throw new BridgeException("wrong_state", "the client is not on the login screen: " + state); + } client.setUsername(username); client.setPassword(password); @@ -512,11 +511,6 @@ private void submitCredentials(String username, String password) throws BridgeEx }); } - /** Build the refusal of a login for a client that is in the given state and not on the login screen. */ - private static BridgeException notOnLoginScreen(GameState state) { - return new BridgeException("wrong_state", "the client is not on the login screen: " + state); - } - /** * Wait until the client is in the game. Throws {@code wrong_state} when the login is refused and * {@code timeout} when the deadline passes first. @@ -533,17 +527,14 @@ private JsonElement awaitLoggedIn(long deadline) throws BridgeException { if (state == GameState.LOGIN_SCREEN) throw new BridgeException("wrong_state", LOGIN_REFUSED); - if (System.currentTimeMillis() >= deadline) throw neverLoggedIn(state); + if (System.currentTimeMillis() >= deadline) { + throw new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); + } pollAgain("the login"); } } - /** Build the timeout for a login that is still in the given state when the wait ends. */ - private static BridgeException neverLoggedIn(GameState state) { - return new BridgeException("timeout", "not logged in before the wait elapsed; gameState is " + state); - } - /** Sleep until the next read of the game state is due. */ private static void pollAgain(String awaited) throws BridgeException { try { From 11e0d526e33fab6c47802106d99527bc305126a5 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:22:12 +0200 Subject: [PATCH 43/72] refactor(mcp-bridge): stop sending two hello fields that rsprox never reads The hello carried the version of RuneLite and that of the plugin. BridgeHub reads hello, httpPort, token and pid, and nothing else in the repository reads the other two. Remove both fields, and with them the manifest attribute of the jar that existed only to supply the plugin version. The fields that rsprox reads are sent as before. Co-Authored-By: Claude Opus 5.5 --- runelite/rsprox-mcp-bridge/build.gradle.kts | 6 ------ .../main/java/net/rsprox/mcpbridge/BridgeConnection.java | 3 --- 2 files changed, 9 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/build.gradle.kts b/runelite/rsprox-mcp-bridge/build.gradle.kts index 6dba0826..ab2b84a3 100644 --- a/runelite/rsprox-mcp-bridge/build.gradle.kts +++ b/runelite/rsprox-mcp-bridge/build.gradle.kts @@ -24,12 +24,6 @@ tasks.withType().configureEach { options.release.set(11) } -tasks.jar { - manifest { - attributes("Implementation-Version" to project.version) - } -} - // The root build applies the Kotlin plugin to every subproject. A sideloaded plugin gets its own class // loader, so anything that leaks into this jar would shadow or duplicate what the client already has. val verifyJarContents by tasks.registering { diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java index 9c4c562b..bc9622f6 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -19,7 +19,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; -import net.runelite.client.RuneLiteProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -110,8 +109,6 @@ static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops hello.addProperty("httpPort", httpPort); hello.add("token", file.get("token")); hello.addProperty("pid", ProcessHandle.current().pid()); - hello.addProperty("runelite", RuneLiteProperties.getVersion()); - hello.addProperty("plugin", BridgeConnection.class.getPackage().getImplementationVersion()); writer.write(gson.toJson(hello)); writer.write('\n'); writer.flush(); From d064a1ea35387d0a0aea84a448355680a65bdcb3 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:22:25 +0200 Subject: [PATCH 44/72] refactor(mcp-bridge): drop the handling of JSON null arguments The server removes every argument that is an explicit null before it runs a tool, and the defaults it forwards are never null, so the plugin receives an argument with a value or not at all. The optional readers now test whether the argument is present, and Ops.present is gone. Behaviour is unchanged for every request the server sends. Co-Authored-By: Claude Opus 5.5 --- .../main/java/net/rsprox/mcpbridge/Ops.java | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index bc995137..a1654ada 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -583,40 +583,26 @@ static String nullToEmpty(String value) { return value == null ? "" : value; } - /** Get the named argument, or null when it is absent or JSON null. */ - private static JsonElement present(JsonObject args, String name) { - JsonElement value = args.get(name); - - return value == null || value.isJsonNull() ? null : value; - } - /** Get the named integer argument, or null when it is absent. */ static Integer optionalInt(JsonObject args, String name) { - JsonElement value = present(args, name); - - return value == null ? null : value.getAsInt(); + return args.has(name) ? args.get(name).getAsInt() : null; } /** Get the named string argument, or null when it is absent. */ static String optionalString(JsonObject args, String name) { - JsonElement value = present(args, name); - - return value == null ? null : value.getAsString(); + return args.has(name) ? args.get(name).getAsString() : null; } /** Get the named boolean argument, or false when it is absent. */ private static boolean optionalBoolean(JsonObject args, String name) { - JsonElement value = present(args, name); - - return value != null && value.getAsBoolean(); + return args.has(name) && args.get(name).getAsBoolean(); } /** Get the named array of integers, or an empty array when it is absent. */ private static int[] optionalInts(JsonObject args, String name) { - JsonElement value = present(args, name); - if (value == null) return new int[0]; + if (!args.has(name)) return new int[0]; - JsonArray array = value.getAsJsonArray(); + JsonArray array = args.getAsJsonArray(name); int[] out = new int[array.size()]; for (int i = 0; i < out.length; i++) { From d53cfdd6debb207805f42d9507fa6023848fbfa4 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:23:25 +0200 Subject: [PATCH 45/72] refactor(mcp-bridge): walk the widget tree in one place The widget listing and the search for the widget that continues a dialog each descended the static, the dynamic and the nested children with the same depth limit and the same skip of hidden widgets. Ops.firstWidget now makes that descent for both, and the listing ends it once its limit is reached. The widgets that are listed and found are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../main/java/net/rsprox/mcpbridge/Ops.java | 79 +++++++++---------- .../java/net/rsprox/mcpbridge/Targets.java | 20 +---- 2 files changed, 40 insertions(+), 59 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index a1654ada..89f06266 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -15,6 +15,7 @@ import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.Arrays; import java.util.Base64; import java.util.HashMap; import java.util.LinkedHashSet; @@ -42,7 +43,7 @@ final class Ops { private static final String WIDGET_REF_SHAPE = "widget must look like \"558:7\" or \"558:7[3]\""; /** The deepest level of nested widgets that a walk or a search descends to. */ - static final int WIDGET_DEPTH_LIMIT = 12; + private static final int WIDGET_DEPTH_LIMIT = 12; /** The pause between two reads of the game state while a login is awaited. */ private static final int LOGIN_POLL_MS = 100; @@ -171,14 +172,15 @@ private JsonElement widgets(JsonObject args) throws BridgeException { int limit = args.get("limit").getAsInt(); return game.onClientThread(client -> { - WidgetWalk walk = new WidgetWalk(group, needle, includeHidden, limit); + WidgetWalk walk = new WidgetWalk(group, needle, limit); Set roots = new LinkedHashSet<>(); for (Widget root : client.getWidgetRoots()) { if (root == null) continue; roots.add(root.getId() >>> 16); - walk.visit(root, 0); + + if (!walk.truncated) firstWidget(root, includeHidden, walk::collect, 0); } JsonObject out = new JsonObject(); @@ -210,56 +212,31 @@ private static final class WidgetWalk { /** The lower-case text to look for, or null to keep every widget. */ private final String needle; - /** Whether hidden widgets are walked too. */ - private final boolean includeHidden; - /** The most widgets to collect. */ private final int limit; /** Create a walk with the given filters. */ - WidgetWalk(Integer group, String needle, boolean includeHidden, int limit) { + WidgetWalk(Integer group, String needle, int limit) { this.group = group; this.needle = needle; - this.includeHidden = includeHidden; this.limit = limit; } - /** Collect the widget and walk its children, unless it is hidden from this walk or too deep. */ - void visit(Widget widget, int depth) { - if (widget == null || truncated || depth > WIDGET_DEPTH_LIMIT) return; - - boolean hidden = widget.isHidden(); - if (hidden && !includeHidden) return; + /** + * Add the widget when it passes the filters, or mark the walk truncated once the limit is + * reached. Returns whether the walk is truncated, which ends it. + */ + boolean collect(Widget widget) { + if (group != null && widget.getId() >>> 16 != group) return false; - collect(widget, hidden); - visitAll(widget.getStaticChildren(), depth); - visitAll(widget.getDynamicChildren(), depth); - visitAll(widget.getNestedChildren(), depth); - } - - /** Add the widget when it passes the filters, or mark the walk truncated once the limit is reached. */ - private void collect(Widget widget, boolean hidden) { - if (group != null && widget.getId() >>> 16 != group) return; + JsonObject described = describe(widget, widget.isHidden()); + if (described == null) return false; - JsonObject described = describe(widget, hidden); - if (described == null) return; + truncated = found.size() >= limit; - if (found.size() >= limit) { - truncated = true; + if (!truncated) found.add(described); - return; - } - - found.add(described); - } - - /** Visit each of the children, one level deeper. */ - private void visitAll(Widget[] children, int depth) { - if (children == null) return; - - for (Widget child : children) { - visit(child, depth + 1); - } + return truncated; } /** Describe the widget. Returns null when it has nothing to read or click, or does not hold the needle. */ @@ -299,6 +276,28 @@ private boolean contains(String haystack) { } } + /** + * Find the first widget under the root, itself included, that passes the test. A widget is tested + * before its static, its dynamic and its nested children, down to the depth limit, and a hidden + * widget is skipped with all under it unless hidden ones are included. Null when none passes. + */ + static Widget firstWidget(Widget root, boolean includeHidden, Predicate test, int depth) { + if (root == null || (root.isHidden() && !includeHidden) || depth > WIDGET_DEPTH_LIMIT) return null; + + if (test.test(root)) return root; + + Widget[] nested = root.getNestedChildren(); + + for (Widget[] children : Arrays.asList(root.getStaticChildren(), root.getDynamicChildren(), nested)) { + for (Widget child : children == null ? new Widget[0] : children) { + Widget found = firstWidget(child, includeHidden, test, depth + 1); + if (found != null) return found; + } + } + + return null; + } + /** Read the requested player variables, varbits and client variables. */ private JsonElement vars(JsonObject args) throws BridgeException { int[] varps = optionalInts(args, "varps"); diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java index f290acd8..421f89e1 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Targets.java @@ -250,7 +250,7 @@ private static List numbered(List choices) { /** Find the visible widget that continues a dialog. Throws {@code not_found} while no dialog waits. */ private static Candidate continueWidget(Client client) throws BridgeException { for (Widget root : client.getWidgetRoots()) { - Widget widget = firstVisible(root, Targets::continues, 0); + Widget widget = Ops.firstWidget(root, false, Targets::continues, 0); if (widget != null) return found(widget); } @@ -269,24 +269,6 @@ private static Candidate found(Widget widget) { return new Candidate(target, List.of(CONTINUE)); } - /** Find the first visible widget under the root, itself included, that passes the test. */ - private static Widget firstVisible(Widget root, Predicate test, int depth) { - if (root == null || root.isHidden() || depth > Ops.WIDGET_DEPTH_LIMIT) return null; - - if (test.test(root)) return root; - - Widget[] nested = root.getNestedChildren(); - - for (Widget[] children : Arrays.asList(root.getStaticChildren(), root.getDynamicChildren(), nested)) { - for (Widget child : children == null ? new Widget[0] : children) { - Widget found = firstVisible(child, test, depth + 1); - if (found != null) return found; - } - } - - return null; - } - /** Find the object that has the id, or shows as it, on the world tile. */ private static Candidate object(Client client, JsonObject args, String option) throws BridgeException { int id = args.get("id").getAsInt(); From 6df7bcd81a02cbe3cf5ab798a7edc201cfef7d8d Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:28:29 +0200 Subject: [PATCH 46/72] test(mcp): let the test hub dial its plugins and close them Three test classes each kept a list of the plugins they dialled and closed them after the test. TestHub now dials and closes them, and its rendezvous file is private to it. No test changes what it asserts. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 17 ++++--------- .../net/rsprox/mcp/bridge/FakePlugin.kt | 17 ++++++++++--- .../net/rsprox/mcp/server/ToolCallsTest.kt | 5 +--- .../mcp/session/SessionManagerRecoveryTest.kt | 25 ++++++------------- 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index 5f2874ee..cd3642a5 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -17,25 +17,18 @@ class BridgeHubTest { private val mapper = jacksonObjectMapper() private val fixture = TestHub() private val hub = fixture.hub - private val plugins = ArrayList() @AfterTest fun cleanUp() { - plugins.forEach { it.close() } fixture.close() } - private fun dial( - httpPort: Int, - token: String? = null, - ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, token).also { plugins += it } - private fun args(json: String = "{}"): ObjectNode = mapper.readTree(json) as ObjectNode /** A welcomed plugin and the link the hub made for it. */ private fun connect(listener: RecordingListener = RecordingListener()): Pair { hub.expect(43650, listener) - val plugin = dial(43650) + val plugin = fixture.dial(43650) assertEquals(1, plugin.read()?.get("welcome")?.asInt()) return plugin to listener.hello.get(10, TimeUnit.SECONDS).first @@ -53,7 +46,7 @@ class BridgeHubTest { fun `a hello for an expected port is welcomed and handed to its listener`() { val listener = RecordingListener(session = "s3") hub.expect(43650, listener) - val plugin = dial(43650) + val plugin = fixture.dial(43650) val welcome = plugin.read()!! assertEquals(1, welcome.get("welcome").asInt()) @@ -67,18 +60,18 @@ class BridgeHubTest { val listener = RecordingListener() hub.expect(43650, listener) - val intruder = dial(43650, token = "not-the-token") + val intruder = fixture.dial(43650, token = "not-the-token") assertEquals("bad token", intruder.read()?.get("reject")?.asText()) assertNull(intruder.read()) assertFalse(listener.hello.isDone) - assertEquals(1, dial(43650).read()?.get("welcome")?.asInt()) + assertEquals(1, fixture.dial(43650).read()?.get("welcome")?.asInt()) } @Test fun `a port that no session expects is rejected`() { hub.expect(43650, RecordingListener()) - val stranger = dial(43600) + val stranger = fixture.dial(43600) assertEquals("no session expects httpPort 43600", stranger.read()?.get("reject")?.asText()) assertNull(stranger.read()) } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index 0c819ceb..dc815c6a 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -27,10 +27,19 @@ internal fun awaitTrue( /** A started hub whose rendezvous file lives in a temporary directory. */ internal class TestHub : AutoCloseable { - val rendezvous: Path = Files.createTempDirectory("mcp-bridge-test").resolve("mcp").resolve("bridge.json") + private val rendezvous = Files.createTempDirectory("mcp-bridge-test").resolve("mcp").resolve("bridge.json") + private val plugins = ArrayList() val hub = BridgeHub(rendezvous, Rendering.GPU).also { it.start() } + /** Connects a plugin that says hello for [httpPort]. The hub's answer is its next [FakePlugin.read]. */ + fun dial( + httpPort: Int, + token: String? = null, + protocol: Int = 1, + ): FakePlugin = FakePlugin.dial(rendezvous, httpPort, token, protocol).also { plugins += it } + override fun close() { + plugins.forEach { it.close() } hub.close() rendezvous.parent.parent .toFile() @@ -98,12 +107,12 @@ internal class FakePlugin private constructor( } companion object { - /** Connects and says hello the way the plugin does. The hub's answer is the next [read]. */ + /** Connects and says hello the way the plugin does. */ fun dial( rendezvous: Path, httpPort: Int, - token: String? = null, - protocol: Int = 1, + token: String?, + protocol: Int, ): FakePlugin { val file = mapper.readTree(Files.readAllBytes(rendezvous)) val socket = Socket(InetAddress.getLoopbackAddress(), file.get("port").asInt()) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt index 08f189ab..b1c6cce2 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt @@ -1,7 +1,6 @@ package net.rsprox.mcp.server import com.fasterxml.jackson.databind.JsonNode -import net.rsprox.mcp.bridge.FakePlugin import net.rsprox.mcp.bridge.TestHub import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.TapSettingSetStore @@ -29,7 +28,6 @@ class ToolCallsTest { private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub) private val server = McpHttpServer(0, tools { manager }, "1.2.3").also { it.start() } private val http = HttpClient.newHttpClient() - private val plugins = ArrayList() private val talkTo = """{"target":"npc","index":0,"option":"Talk-to"}""" // Every request the plugin received, as its op and its arguments. @@ -61,7 +59,6 @@ class ToolCallsTest { @AfterTest fun cleanUp() { server.close() - plugins.forEach { it.close() } fixture.close() } @@ -115,7 +112,7 @@ class ToolCallsTest { private fun connect() { call("session_start", """{"target":"My Server","wait_ms":0}""") - val plugin = FakePlugin.dial(fixture.rendezvous, FakeLauncher.FIRST_HTTP_PORT).also { plugins += it } + val plugin = fixture.dial(FakeLauncher.FIRST_HTTP_PORT) plugin.read() plugin.serve { op, args -> received += "$op $args" diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt index e2fe0c64..a528b168 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt @@ -1,6 +1,5 @@ package net.rsprox.mcp.session -import net.rsprox.mcp.bridge.FakePlugin import net.rsprox.mcp.bridge.TestHub import net.rsprox.mcp.bridge.awaitTrue import net.rsprox.mcp.packets.PacketQuery @@ -19,11 +18,9 @@ import kotlin.test.assertTrue class SessionManagerRecoveryTest { private val launcher = FakeLauncher() private val fixture = TestHub() - private val plugins = ArrayList() @AfterTest fun cleanUp() { - plugins.forEach { it.close() } fixture.close() } @@ -32,12 +29,6 @@ class SessionManagerRecoveryTest { helloTimeoutMs: Long = 60_000, ) = SessionManager(launcher, TapSettingSetStore, fixture.hub, launchTimeoutMs, helloTimeoutMs) - private fun dial( - httpPort: Int, - token: String? = null, - protocol: Int = 1, - ): FakePlugin = FakePlugin.dial(fixture.rendezvous, httpPort, token, protocol).also { plugins += it } - private fun SessionManager.awaitStopped(session: String): SessionSnapshot { awaitTrue("session $session is stopped") { list().single { it.session == session }.state == "stopped" } @@ -47,7 +38,7 @@ class SessionManagerRecoveryTest { private fun SessionManager.markers(session: String): List = resolve(session).packets.read(PacketQuery()).packets.map { it.prot } - private fun rejection(httpPort: Int): String? = dial(httpPort).read()?.get("reject")?.asText() + private fun rejection(httpPort: Int): String? = fixture.dial(httpPort).read()?.get("reject")?.asText() @Test fun `a client whose plugin never says hello is killed and its session stops with the reason`() { @@ -67,7 +58,7 @@ class SessionManagerRecoveryTest { fun `a client whose plugin says hello in time stays connected after the deadline`() { val manager = manager(helloTimeoutMs = 1_000) manager.start(null, null, 0) - dial(FakeLauncher.FIRST_HTTP_PORT) + fixture.dial(FakeLauncher.FIRST_HTTP_PORT) assertEquals("connected", manager.start(null, "s1", 10_000).state) // The deadlines pass in the order of the launches, so that of s1 has passed once s2 is stopped. @@ -83,12 +74,12 @@ class SessionManagerRecoveryTest { val manager = manager() manager.start(null, null, 0) - assertEquals("bad token", dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) + assertEquals("bad token", fixture.dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) assertEquals("launching", manager.list().single().state) assertEquals(emptyList(), launcher.killed) - dial(43650) + fixture.dial(43650) assertEquals("connected", manager.start(null, "s1", 10_000).state) } @@ -98,7 +89,7 @@ class SessionManagerRecoveryTest { val manager = manager() manager.start(null, null, 0) - dial(43650, protocol = 2).read() + fixture.dial(43650, protocol = 2).read() assertEquals( "the client started, but its bridge plugin was rejected: " + @@ -111,10 +102,10 @@ class SessionManagerRecoveryTest { fun `a rejected hello that names the port of a connected session leaves that session alone`() { val manager = manager() manager.start(null, null, 0) - dial(43650) + fixture.dial(43650) assertEquals("connected", manager.start(null, "s1", 10_000).state) - assertEquals("bad token", dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) + assertEquals("bad token", fixture.dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) assertEquals("no session expects httpPort 43650", rejection(43650)) assertEquals("connected", manager.list().single().state) @@ -152,7 +143,7 @@ class SessionManagerRecoveryTest { fun `a launch whose launcher exits fails at once and leaves the other sessions usable`() { val manager = manager() manager.start(null, null, 0) - val plugin = dial(43650) + val plugin = fixture.dial(43650) plugin.read() assertEquals("connected", manager.start(null, "s1", 10_000).state) plugin.serve { _, _ -> """"ok":{"tick":7}""" } From 427b35691ac627e9cfe17ccf4815c5a4a2c63c4b Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:30:53 +0200 Subject: [PATCH 47/72] test(mcp): pin in the hub test that a bad token is not reported The session test of a hello with a bad token repeated the hub test of the same branch. The one defect it alone caught was a hub that reports the bad token to the waiting session, which then stops. The recording listener now records what it is told was rejected, the hub test asserts that a bad token tells it nothing, and the session test is removed. With the hub changed to report a bad token, the hub test fails. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt | 1 + .../kotlin/net/rsprox/mcp/bridge/FakePlugin.kt | 4 +++- .../mcp/session/SessionManagerRecoveryTest.kt | 15 --------------- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index cd3642a5..a75857fa 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -66,6 +66,7 @@ class BridgeHubTest { assertFalse(listener.hello.isDone) assertEquals(1, fixture.dial(43650).read()?.get("welcome")?.asInt()) + assertEquals(emptyList(), listener.rejected) } @Test diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index dc815c6a..22b01867 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -8,6 +8,7 @@ import java.net.Socket import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList import kotlin.concurrent.thread private val mapper = jacksonObjectMapper() @@ -52,6 +53,7 @@ internal class RecordingListener( ) : BridgeListener { val hello = CompletableFuture>() val closed = CompletableFuture() + val rejected = CopyOnWriteArrayList() override fun onHello( link: BridgeLink, @@ -67,7 +69,7 @@ internal class RecordingListener( } override fun onRejected(reason: String) { - // + rejected += reason } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt index a528b168..03ce5191 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt @@ -69,21 +69,6 @@ class SessionManagerRecoveryTest { assertEquals("connected", manager.list().first().state) } - @Test - fun `a hello with a bad token is rejected and leaves the launching session waiting for its client`() { - val manager = manager() - manager.start(null, null, 0) - - assertEquals("bad token", fixture.dial(43650, token = "not-the-token").read()?.get("reject")?.asText()) - - assertEquals("launching", manager.list().single().state) - assertEquals(emptyList(), launcher.killed) - - fixture.dial(43650) - - assertEquals("connected", manager.start(null, "s1", 10_000).state) - } - @Test fun `a hello of a stale plugin jar stops the session and says the jar is stale`() { val manager = manager() From 85c1e9f70387c41b96e2cffdfc472bcd4f8872d4 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:32:08 +0200 Subject: [PATCH 48/72] test(mcp): call the tools through one shared test server ToolCallsTest, AttachedSessionTest and McpExtensionTest each built the same launcher, hub, session manager and endpoint, the same login header and the same JSON-RPC helpers. ToolServer now holds them once. Its helpers take the stricter form of the copies: a tool result must hold exactly one block of text. McpExtensionTest now compares the whole answer of session_list and the port that was bound. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/McpExtensionTest.kt | 45 +++--- .../rsprox/mcp/server/AttachedSessionTest.kt | 135 ++++-------------- .../net/rsprox/mcp/server/ToolCallsTest.kt | 135 ++++-------------- .../net/rsprox/mcp/server/ToolServer.kt | 93 ++++++++++++ .../net/rsprox/mcp/session/SessionFixtures.kt | 20 +++ 5 files changed, 185 insertions(+), 243 deletions(-) create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt index 59c97d57..b1e583d8 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt @@ -1,19 +1,11 @@ package net.rsprox.mcp -import net.rsprox.mcp.bridge.TestHub -import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.server.McpHttpServer -import net.rsprox.mcp.session.FakeLauncher -import net.rsprox.mcp.session.SessionManager +import net.rsprox.mcp.server.ToolServer import net.rsprox.proxy.ProxyExtension import java.net.ServerSocket -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse import java.util.ServiceLoader import kotlin.test.Test -import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertNull @@ -49,26 +41,21 @@ class McpExtensionTest { @Test fun `a free port serves the tools over the sessions that were built`() { - val port = freePort() - val fixture = TestHub() - val endpoint = serveOrNull(port) { SessionManager(FakeLauncher(), TapSettingSetStore, fixture.hub) } - - try { - val request = - HttpRequest - .newBuilder(URI("http://127.0.0.1:$port/mcp")) - .POST( - HttpRequest.BodyPublishers.ofString( - """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_list"}}""", - ), - ).build() - - val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) - - assertContains(response.body(), """{\"sessions\":[],\"targets\":[\"Old School RuneScape\",\"My Server\"]}""") - } finally { - endpoint?.close() - fixture.close() + var port = 0 + + // The port is picked once the test server holds its other ports, so that none of them takes it. + val served = + ToolServer { sessions -> + port = freePort() + checkNotNull(serveOrNull(port) { sessions }) + } + + served.use { server -> + assertEquals(port, server.port) + assertEquals( + """{"sessions":[],"targets":["Old School RuneScape","My Server"]}""", + server.call("session_list"), + ) } } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt index 20025e06..63cc4d3c 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -1,112 +1,37 @@ package net.rsprox.mcp.server -import com.fasterxml.jackson.databind.JsonNode -import net.rsprox.mcp.bridge.TestHub -import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.session.FakeLauncher -import net.rsprox.mcp.session.SessionManager +import net.rsprox.mcp.session.loginHeader import net.rsprox.mcp.session.target import net.rsprox.proxy.binary.BinaryHeader import net.rsprox.shared.SessionMonitor import net.rsprox.shared.StreamDirection import net.rsprox.shared.property.ChildProperty import net.rsprox.shared.property.RootProperty -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull class AttachedSessionTest { - private val launcher = FakeLauncher() - private val fixture = TestHub() - private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub) - private val server = McpHttpServer(0, tools { manager }, "1.2.3").also { it.start() } - private val http = HttpClient.newHttpClient() - - private val header = - BinaryHeader( - headerVersion = 1, - revision = 235, - subRevision = 1, - clientType = 1, - platformType = 1, - timestamp = 1_700_000_000_000, - worldId = 301, - worldFlags = 0, - worldLocation = 0, - worldHost = "127.0.1.3", - worldActivity = "", - localPlayerIndex = 7, - accountHash = ByteArray(0), - clientName = "RuneLite", - js5MasterIndex = ByteArray(0), - ) + private val server = ToolServer() @AfterTest fun cleanUp() { server.close() - fixture.close() } - private fun toolResult( - tool: String, - arguments: String, - ): JsonNode { - val body = - """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"$tool","arguments":$arguments}}""" - - val request = - HttpRequest - .newBuilder(URI("http://127.0.0.1:${server.localPort}/mcp")) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build() - - val response = http.send(request, HttpResponse.BodyHandlers.ofString()) - - return McpDispatcher.MAPPER.readTree(response.body()).get("result") - } - - private fun call( - tool: String, - arguments: String = "{}", - ): String { - val result = toolResult(tool, arguments) - val text = result.get("content").single().get("text").asText() - assertEquals(false, result.get("isError").asBoolean(), text) - - return text - } - - private fun error( - tool: String, - arguments: String, - ): String { - val result = toolResult(tool, arguments) - val text = result.get("content").single().get("text").asText() - assertEquals(true, result.get("isError").asBoolean(), text) - - return text - } - - private fun sessions(): List = - McpDispatcher.MAPPER - .readTree(call("session_list")) - .get("sessions") - .map { it.toString() } + private fun sessions(): List = server.callJson("session_list").get("sessions").map { it.toString() } private fun attach( proxyPort: Int = 43701, captureFolder: String? = "Local", ): SessionMonitor = - checkNotNull(manager.attach(proxyPort, target(1, "Local").copy(binaryFolder = captureFolder))) + checkNotNull(server.manager.attach(proxyPort, target(1, "Local").copy(binaryFolder = captureFolder))) private fun SessionMonitor.logIn(): SessionMonitor { - val login = forSession(header) - login.onLogin(header) + val login = forSession(loginHeader) + login.onLogin(loginHeader) login.onCacheUpdate { error("the test formats no packet that needs the cache") } return login @@ -127,11 +52,9 @@ class AttachedSessionTest { ) } - private fun rows(arguments: String): List = call("packets_read", arguments).lines().drop(1) - @Test fun `a client the GUI launches is listed as an attached session, numbered with the launched ones`() { - call("session_start", """{"target":"My Server","wait_ms":0}""") + server.call("session_start", """{"target":"My Server","wait_ms":0}""") attach(proxyPort = 43701) @@ -148,11 +71,11 @@ class AttachedSessionTest { @Test fun `a client that the MCP server launched itself gets no attached session`() { - call("session_start", """{"target":"My Server","wait_ms":0}""") + server.call("session_start", """{"target":"My Server","wait_ms":0}""") - assertNull(manager.attach(FakeLauncher.FIRST_PROXY_PORT, target(1, "My Server"))) + assertNull(server.manager.attach(FakeLauncher.FIRST_PROXY_PORT, target(1, "My Server"))) - assertEquals(listOf("s1"), manager.list().map { it.session }) + assertEquals(listOf("s1"), server.manager.list().map { it.session }) } @Test @@ -165,10 +88,10 @@ class AttachedSessionTest { assertEquals(listOf("""$attached,"cursor":1}"""), sessions()) - val login = client.forSession(header) - login.onLogin(header) + val login = client.forSession(loginHeader) + login.onLogin(loginHeader) - val recorded = """$accepted,"captureFile":"Local/${header.fileName()}"""" + val recorded = """$accepted,"captureFile":"Local/${loginHeader.fileName()}"""" assertEquals(listOf("""$attached,$recorded,"online":true,"transcribing":false},"cursor":2}"""), sessions()) @@ -187,7 +110,7 @@ class AttachedSessionTest { sessions(), ) - login.onLogout(header) + login.onLogout(loginHeader) assertEquals( listOf("""$attached,$recorded,"name":"Alice","online":false,"transcribing":true,"tick":5},"cursor":5}"""), @@ -215,7 +138,7 @@ class AttachedSessionTest { val first = client.logIn() first.packet(tick = 4, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") first.packet(tick = 5, StreamDirection.CLIENT_TO_SERVER, "NO_TIMEOUT") - first.onLogout(header) + first.onLogout(loginHeader) client.logIn().packet(tick = 1, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") assertEquals( @@ -229,10 +152,10 @@ class AttachedSessionTest { "6 L2 T0 P LOGIN revision=235 world=301", "7 L2 T1 S REBUILD_NORMAL [rebuild_normal] ", ), - call("packets_read", """{"session":"s1"}""").lines(), + server.call("packets_read", """{"session":"s1"}""").lines(), ) - assertEquals(listOf("4 L1 T5 C NO_TIMEOUT [no_timeout] "), rows("""{"after":3,"origin":"client"}""")) + assertEquals(listOf("4 L1 T5 C NO_TIMEOUT [no_timeout] "), server.rows("""{"after":3,"origin":"client"}""")) } @Test @@ -242,12 +165,12 @@ class AttachedSessionTest { "is not available for an attached session: session s1 belongs to a client that was launched " + "from the rsprox GUI, so only its packets can be read" - assertEquals("session_stop $refusal", error("session_stop", """{"session":"s1"}""")) - assertEquals("session_start $refusal", error("session_start", """{"session":"s1","wait_ms":0}""")) + assertEquals("session_stop $refusal", server.error("session_stop", """{"session":"s1"}""")) + assertEquals("session_start $refusal", server.error("session_start", """{"session":"s1","wait_ms":0}""")) - assertEquals(emptyList(), launcher.killed) - assertEquals(emptyList(), launcher.reserved) - assertEquals("attached", manager.list().single().state) + assertEquals(emptyList(), server.launcher.killed) + assertEquals(emptyList(), server.launcher.reserved) + assertEquals("attached", server.manager.list().single().state) } @Test @@ -260,7 +183,7 @@ class AttachedSessionTest { "client_interact" to """{"target":"tile","x":3094,"y":3107}""", ) - val clientTools = tools { manager }.map { it.name }.filter { it.startsWith("client_") } + val clientTools = tools { server.manager }.map { it.name }.filter { it.startsWith("client_") } assertEquals(10, clientTools.size) @@ -268,7 +191,7 @@ class AttachedSessionTest { assertEquals( "$tool is not available for an attached session: session s1 belongs to a client that was " + "launched from the rsprox GUI, so only its packets can be read", - error(tool, required[tool] ?: "{}"), + server.error(tool, required[tool] ?: "{}"), ) } } @@ -278,13 +201,13 @@ class AttachedSessionTest { attach(proxyPort = 43701).logIn() attach(proxyPort = 43702) - manager.detach(43701) - manager.detach(43701) - manager.detach(43999) + server.manager.detach(43701) + server.manager.detach(43701) + server.manager.detach(43999) assertEquals( listOf("ended" to "the client was closed", "attached" to null), - manager.list().map { it.state to it.reason }, + server.manager.list().map { it.state to it.reason }, ) assertEquals( @@ -293,7 +216,7 @@ class AttachedSessionTest { "2 L1 T0 P LOGIN revision=235 world=301", "3 L1 T0 P CLIENT_EXITED the client was closed", ), - rows("""{"session":"s1"}"""), + server.rows("""{"session":"s1"}"""), ) } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt index b1c6cce2..e92a31d5 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolCallsTest.kt @@ -1,17 +1,10 @@ package net.rsprox.mcp.server import com.fasterxml.jackson.databind.JsonNode -import net.rsprox.mcp.bridge.TestHub import net.rsprox.mcp.packets.Origin -import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.session.FakeLauncher -import net.rsprox.mcp.session.SessionManager -import net.rsprox.proxy.binary.BinaryHeader +import net.rsprox.mcp.session.loginHeader import net.rsprox.shared.StreamDirection -import java.net.URI -import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse import java.util.concurrent.CompletableFuture import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.TimeUnit @@ -23,11 +16,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class ToolCallsTest { - private val launcher = FakeLauncher() - private val fixture = TestHub() - private val manager = SessionManager(launcher, TapSettingSetStore, fixture.hub) - private val server = McpHttpServer(0, tools { manager }, "1.2.3").also { it.start() } - private val http = HttpClient.newHttpClient() + private val server = ToolServer() private val talkTo = """{"target":"npc","index":0,"option":"Talk-to"}""" // Every request the plugin received, as its op and its arguments. @@ -37,95 +26,27 @@ class ToolCallsTest { @Volatile private var answer: (args: JsonNode) -> String = { """"ok":{}""" } - private val header = - BinaryHeader( - headerVersion = 1, - revision = 235, - subRevision = 1, - clientType = 1, - platformType = 1, - timestamp = 0, - worldId = 301, - worldFlags = 0, - worldLocation = 0, - worldHost = "127.0.0.1", - worldActivity = "", - localPlayerIndex = 1, - accountHash = ByteArray(0), - clientName = "RuneLite", - js5MasterIndex = ByteArray(0), - ) - @AfterTest fun cleanUp() { server.close() - fixture.close() - } - - private fun rpc( - method: String, - params: String, - ): JsonNode { - val body = """{"jsonrpc":"2.0","id":1,"method":"$method","params":$params}""" - val request = - HttpRequest - .newBuilder(URI("http://127.0.0.1:${server.localPort}/mcp")) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build() - - val response = http.send(request, HttpResponse.BodyHandlers.ofString()) - - return McpDispatcher.MAPPER.readTree(response.body()).get("result") - } - - private fun toolResult( - tool: String, - arguments: String, - ): JsonNode = rpc("tools/call", """{"name":"$tool","arguments":$arguments}""") - - private fun call( - tool: String, - arguments: String = "{}", - ): String { - val result = toolResult(tool, arguments) - val text = result.get("content").last().get("text").asText() - assertFalse(result.get("isError").asBoolean(), text) - - return text - } - - private fun callJson( - tool: String, - arguments: String = "{}", - ): JsonNode = McpDispatcher.MAPPER.readTree(call(tool, arguments)) - - private fun error( - tool: String, - arguments: String, - ): String { - val result = toolResult(tool, arguments) - val text = result.get("content").single().get("text").asText() - assertTrue(result.get("isError").asBoolean(), text) - - return text } private fun connect() { - call("session_start", """{"target":"My Server","wait_ms":0}""") - val plugin = fixture.dial(FakeLauncher.FIRST_HTTP_PORT) + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT) plugin.read() plugin.serve { op, args -> received += "$op $args" answer(args) } - val started = callJson("session_start", """{"session":"s1","wait_ms":10000}""") + val started = server.callJson("session_start", """{"session":"s1","wait_ms":10000}""") assertEquals("connected", started.get("state").asText()) } private fun logIn(decoded: Boolean = true) { - val tap = launcher.monitor.forSession(header) - tap.onLogin(header) + val tap = server.launcher.monitor.forSession(loginHeader) + tap.onLogin(loginHeader) if (decoded) tap.onPacketDirection(StreamDirection.CLIENT_TO_SERVER) } @@ -135,7 +56,7 @@ class ToolCallsTest { text: String, origin: Origin = Origin.CLIENT, tick: Int = 7, - ): Long = manager.resolve("s1").packets.append(1, tick, origin, prot, text) + ): Long = server.manager.resolve("s1").packets.append(1, tick, origin, prot, text) private fun clicked( x: Int, @@ -153,11 +74,9 @@ class ToolCallsTest { """"ok":{"target":"$target","option":"Talk-to","attempts":1,"tick":41,""" + """"expect":"$expect","pressed":[312,171],"tried":[]}""" - private fun rows(arguments: String): List = call("packets_read", arguments).lines().drop(1) - @Test fun `the tool list names the fourteen tools, each with a description and an object schema`() { - val listed = rpc("tools/list", "{}").get("tools") + val listed = server.rpc("tools/list", "{}").get("tools") assertEquals( listOf( @@ -193,7 +112,7 @@ class ToolCallsTest { """"ok":{"x":10,"y":20}""" } - val result = call("client_click", """{"session":"s1","x":10,"y":20,"widget":null}""") + val result = server.call("client_click", """{"session":"s1","x":10,"y":20,"widget":null}""") assertEquals("""{"x":10,"y":20,"cursor":2}""", result) assertEquals(listOf("""click {"x":10,"y":20}"""), received) @@ -203,10 +122,10 @@ class ToolCallsTest { fun `invalid arguments are refused by name before anything reaches the plugin`() { connect() - assertContains(error("client_click", """{"x":"ten","y":20}"""), "'x' must be of type integer") - assertContains(error("client_click", """{"x":10,"y":20,"z":1}"""), "unknown argument 'z'") + assertContains(server.error("client_click", """{"x":"ten","y":20}"""), "'x' must be of type integer") + assertContains(server.error("client_click", """{"x":10,"y":20,"z":1}"""), "unknown argument 'z'") assertContains( - error("client_entities", """{"kinds":["npc","dragon"]}"""), + server.error("client_entities", """{"kinds":["npc","dragon"]}"""), "every item of 'kinds' must be one of npc, object", ) @@ -224,7 +143,7 @@ class ToolCallsTest { performed("npc", "OPNPC") } - val result = callJson("client_interact", talkTo) + val result = server.callJson("client_interact", talkTo) assertEquals("6 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308)", result.get("packet").asText()) assertEquals("click", result.get("proof").asText()) @@ -245,7 +164,7 @@ class ToolCallsTest { performed("npc", "OPNPC") } - val result = callJson("client_interact", talkTo) + val result = server.callJson("client_interact", talkTo) assertEquals("5 L1 T7 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, id=3308)", result.get("packet").asText()) assertEquals("prefix", result.get("proof").asText()) @@ -260,8 +179,8 @@ class ToolCallsTest { } // Each call waits three seconds for a packet, so the two wait side by side. - val npc = CompletableFuture.supplyAsync { error("client_interact", talkTo) } - val widget = callJson("client_interact", """{"target":"widget","widget":"558:7","option":"Toggle"}""") + val npc = CompletableFuture.supplyAsync { server.error("client_interact", talkTo) } + val widget = server.callJson("client_interact", """{"target":"widget","widget":"558:7","option":"Toggle"}""") assertContains(npc.get(10, TimeUnit.SECONDS), "no OPNPC packet") assertContains(npc.get(), "the action was dropped. Likely cause: the NPC left the client's view") @@ -280,7 +199,7 @@ class ToolCallsTest { performed("widget", "IF_BUTTON") } - val option = callJson("client_interact", """{"target":"dialog","option":"2"}""") + val option = server.callJson("client_interact", """{"target":"dialog","option":"2"}""") assertEquals("5 L1 T7 C RESUME_PAUSEBUTTON [resume_pausebutton] com=219:1", option.get("packet").asText()) assertEquals("click", option.get("proof").asText()) @@ -291,7 +210,7 @@ class ToolCallsTest { performed("widget", "IF_BUTTON") } - val close = callJson("client_interact", """{"target":"widget","widget":"12:2","option":"Close"}""") + val close = server.callJson("client_interact", """{"target":"widget","widget":"12:2","option":"Close"}""") assertEquals("7 L1 T7 C CLOSE_MODAL [close_modal]", close.get("packet").asText()) assertEquals("click", close.get("proof").asText()) @@ -303,7 +222,7 @@ class ToolCallsTest { logIn(decoded = false) answer = { performed("npc", "OPNPC") } - val result = callJson("client_interact", talkTo) + val result = server.callJson("client_interact", talkTo) assertTrue(result.get("packet").isNull, result.toString()) assertContains(result.get("note").asText(), "not decoded, so the action could not be confirmed") @@ -326,13 +245,13 @@ class ToolCallsTest { 5 L1 T8 S IF_SETTEXT [if_settext] com=558:7, text=Hello there 6 L1 T8 C IF_BUTTONX [if_buttonx] com=558:7, op=1 """.trimIndent(), - call("packets_read", """{"after":2}"""), + server.call("packets_read", """{"after":2}"""), ) - assertEquals(listOf("5", "6"), rows("""{"after":4}""").map { it.substringBefore(' ') }) - assertEquals(listOf("5"), rows("""{"prots":["if_settext"]}""").map { it.substringBefore(' ') }) - assertEquals(listOf("4", "6"), rows("""{"origin":"client"}""").map { it.substringBefore(' ') }) - assertEquals(listOf("5"), rows("""{"contains":"hello THERE"}""").map { it.substringBefore(' ') }) + assertEquals(listOf("5", "6"), server.rows("""{"after":4}""").map { it.substringBefore(' ') }) + assertEquals(listOf("5"), server.rows("""{"prots":["if_settext"]}""").map { it.substringBefore(' ') }) + assertEquals(listOf("4", "6"), server.rows("""{"origin":"client"}""").map { it.substringBefore(' ') }) + assertEquals(listOf("5"), server.rows("""{"contains":"hello THERE"}""").map { it.substringBefore(' ') }) } @Test @@ -340,7 +259,7 @@ class ToolCallsTest { connect() logIn() val read = """{"after":3,"contains":"farewell","wait_ms":60000}""" - val waiting = CompletableFuture.supplyAsync { call("packets_read", read) } + val waiting = CompletableFuture.supplyAsync { server.call("packets_read", read) } sent("IF_SETTEXT", "[if_settext] com=558:7, text=Hello there", Origin.SERVER) sent("IF_SETTEXT", "[if_settext] com=558:7, text=Farewell", Origin.SERVER) @@ -355,7 +274,7 @@ class ToolCallsTest { assertEquals( """{"next":5,"head":5,"dropped":0,"timedOut":true,"count":0}""", - call("packets_read", """{"after":5,"wait_ms":50}"""), + server.call("packets_read", """{"after":5,"wait_ms":50}"""), ) } } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt new file mode 100644 index 00000000..07d6d703 --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt @@ -0,0 +1,93 @@ +package net.rsprox.mcp.server + +import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.bridge.TestHub +import net.rsprox.mcp.packets.TapSettingSetStore +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.SessionManager +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The tools over a fake launcher and a started bridge hub, called over HTTP the way an MCP client calls them. + * [serve] starts the endpoint over the sessions, on a free port unless a test says otherwise. + */ +internal class ToolServer( + serve: (SessionManager) -> McpHttpServer = { sessions -> + McpHttpServer(0, tools { sessions }, "1.2.3").also { it.start() } + }, +) : AutoCloseable { + val launcher = FakeLauncher() + val hub = TestHub() + val manager = SessionManager(launcher, TapSettingSetStore, hub.hub) + private val server = serve(manager) + private val http = HttpClient.newHttpClient() + + val port: Int get() = server.localPort + + /** Sends one JSON-RPC request and returns its `result`. */ + fun rpc( + method: String, + params: String, + ): JsonNode { + val body = """{"jsonrpc":"2.0","id":1,"method":"$method","params":$params}""" + val request = + HttpRequest + .newBuilder(URI("http://127.0.0.1:$port/mcp")) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build() + + val response = http.send(request, HttpResponse.BodyHandlers.ofString()) + + return McpDispatcher.MAPPER.readTree(response.body()).get("result") + } + + /** Calls a tool that must succeed and returns its text. */ + fun call( + tool: String, + arguments: String = "{}", + ): String { + val (text, isError) = toolResult(tool, arguments) + assertFalse(isError, text) + + return text + } + + /** Calls a tool that must succeed and returns its text as JSON. */ + fun callJson( + tool: String, + arguments: String = "{}", + ): JsonNode = McpDispatcher.MAPPER.readTree(call(tool, arguments)) + + /** Calls a tool that must fail and returns its message. */ + fun error( + tool: String, + arguments: String, + ): String { + val (text, isError) = toolResult(tool, arguments) + assertTrue(isError, text) + + return text + } + + /** Reads packets and returns their lines, without the line of meta. */ + fun rows(arguments: String): List = call("packets_read", arguments).lines().drop(1) + + override fun close() { + server.close() + hub.close() + } + + private fun toolResult( + tool: String, + arguments: String, + ): Pair { + val result = rpc("tools/call", """{"name":"$tool","arguments":$arguments}""") + + return result.get("content").single().get("text").asText() to result.get("isError").asBoolean() + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt index c36ed7e3..13bddb1b 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt @@ -22,6 +22,26 @@ internal fun target( binaryFolder = null, ) +/** The header of a login to world 301, which the proxy hands to a session monitor. */ +internal val loginHeader = + BinaryHeader( + headerVersion = 1, + revision = 235, + subRevision = 1, + clientType = 1, + platformType = 1, + timestamp = 1_700_000_000_000, + worldId = 301, + worldFlags = 0, + worldLocation = 0, + worldHost = "127.0.1.3", + worldActivity = "", + localPlayerIndex = 7, + accountHash = ByteArray(0), + clientName = "RuneLite", + js5MasterIndex = ByteArray(0), + ) + internal class FakeLauncher : ClientLauncher { private val targets = listOf(target(0, "Old School RuneScape"), target(1, "My Server")) val reserved = ArrayList() From 168c1d047a681bc03ffaad036faf8a2732b17413 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:32:37 +0200 Subject: [PATCH 49/72] test(mcp): drop a packet log test that repeats another A read that does not wait returns through one statement, whether or not the query has a filter. The test of an empty log already asserts that such a read reports no timeout, so the second test of the same statement is gone. Co-Authored-By: Claude Opus 5.5 --- mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt index a2d8e70b..7d27017c 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/packets/PacketLogTest.kt @@ -130,12 +130,6 @@ class PacketLogTest { assertEquals(Cursor(1), page.next) } - @Test - fun `a read without a wait never reports a timeout`() { - val log = PacketLog() - assertFalse(log.read(PacketQuery(prots = setOf("WANTED")), waitMs = 0).timedOut) - } - @Test fun `a waiting read with matches already present returns at once`() { val log = PacketLog() From ef1db385cabf1eb734ca7d22a0971530b9f35095 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:35:08 +0200 Subject: [PATCH 50/72] test(proxy): fold four fan-out monitor tests into two The test that every callback reaches each monitor is now the test of an observer that throws, which also asserts what the failing observer heard. The test of the monitors of a login is now the test of an observer that fails to make its login monitor, which also asserts the forSession calls. Each merged test fails when the fan-out drops a forward to the primary monitor or to an observer, or stops guarding an observer. Co-Authored-By: Claude Opus 5.5 --- .../proxy/util/FanOutSessionMonitorTest.kt | 40 +++++-------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt index 662c9e8e..741ba790 100644 --- a/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt +++ b/proxy/src/test/kotlin/net/rsprox/proxy/util/FanOutSessionMonitorTest.kt @@ -86,49 +86,29 @@ class FanOutSessionMonitorTest { ) @Test - fun `every callback reaches the primary monitor and each observer`() { - val primary = Recorder() - val first = Recorder() - val second = Recorder() - - everyCallback(FanOutSessionMonitor(primary, listOf(first, second))) - - assertEquals(everyCall, primary.calls) - assertEquals(everyCall, first.calls) - assertEquals(everyCall, second.calls) - } - - @Test - fun `the callbacks of a login reach the login monitor of the primary and of each observer`() { - val primary = Recorder() - val observer = Recorder() - - everyCallback(FanOutSessionMonitor(primary, listOf(observer)).forSession("a")) - - assertEquals(listOf("forSession a"), primary.calls) - assertEquals(listOf("forSession a"), observer.calls) - assertEquals(everyCall.map { "a:$it" }, primary.logins.single().calls) - assertEquals(everyCall.map { "a:$it" }, observer.logins.single().calls) - } - - @Test - fun `an observer that throws keeps no callback from the primary monitor or from a later observer`() { + fun `every callback reaches the primary monitor and each observer, past an observer that throws`() { val primary = Recorder() + val failing = Recorder(failing = true) val later = Recorder() - everyCallback(FanOutSessionMonitor(primary, listOf(Recorder(failing = true), later))) + everyCallback(FanOutSessionMonitor(primary, listOf(failing, later))) assertEquals(everyCall, primary.calls) + assertEquals(everyCall, failing.calls) assertEquals(everyCall, later.calls) } @Test - fun `an observer that fails to make its login monitor is left out of that login`() { + fun `the callbacks of a login reach the login monitors, less that of an observer that fails to make one`() { val primary = Recorder() + val failing = Recorder(failing = true) val later = Recorder() - everyCallback(FanOutSessionMonitor(primary, listOf(Recorder(failing = true), later)).forSession("a")) + everyCallback(FanOutSessionMonitor(primary, listOf(failing, later)).forSession("a")) + assertEquals(listOf("forSession a"), primary.calls) + assertEquals(listOf("forSession a"), failing.calls) + assertEquals(listOf("forSession a"), later.calls) assertEquals(everyCall.map { "a:$it" }, primary.logins.single().calls) assertEquals(everyCall.map { "a:$it" }, later.logins.single().calls) } From cb86780b895910acaa551dea2648b0b9c4392d6b Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:35:38 +0200 Subject: [PATCH 51/72] test(mcp): cover the login monitor asked for its own login The monitor of a login answers forSession with itself. Without that override it answered with the monitor that does nothing, and no test failed. A new case sends a packet through the monitor that a second forSession returns and reads it under the same login number. The description of session_list said that captureFile is absent until it is known. It is present from the login, and absent only for a target that is not recorded, so the description now says that. Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt | 5 +++-- .../kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 97b6d81a..b051f52a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -70,8 +70,9 @@ public fun tools(sessions: () -> SessionManager): List = "`reason` says why a session has no client. Once the client has logged in, `login` holds " + "`epoch` (the L number of its packets), `revision`, `world`, `host`, `localPlayerIndex`, " + "`connectedAt`, `online` (whether it is still logged in) and `transcribing` (whether its " + - "packets are decoded). `captureFile`, the recording under the rsprox binary directory, " + - "`name`, the display name, and `tick`, the newest server tick seen, are absent until known.", + "packets are decoded). `captureFile`, the recording under the rsprox binary directory, is " + + "absent for a target that is not recorded. `name`, the display name, and `tick`, the " + + "newest server tick seen, are absent until known.", inputSchema = schema(), ) { val manager = sessions() diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt index 63cc4d3c..32cbe084 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -158,6 +158,15 @@ class AttachedSessionTest { assertEquals(listOf("4 L1 T5 C NO_TIMEOUT [no_timeout] "), server.rows("""{"after":3,"origin":"client"}""")) } + @Test + fun `the monitor of a login is its own monitor for that login`() { + val login = attach().logIn() + + login.forSession(loginHeader).packet(tick = 4, StreamDirection.SERVER_TO_CLIENT, "REBUILD_NORMAL") + + assertEquals(listOf("3 L1 T4 S REBUILD_NORMAL [rebuild_normal] "), server.rows("""{"after":2}""")) + } + @Test fun `session_stop and session_start refuse an attached session and leave its client alone`() { attach() From 54de4ebc9fe8a9b43a4adb577c73f971cca4f135 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:38:23 +0200 Subject: [PATCH 52/72] docs(mcp): cut the README to what the tool descriptions do not say The README restated the descriptions that tools/list returns, described how the plugin aims and how a packet is tied to a click, and held a manual test plan and two worked examples. It now keeps how to run, register and trust the server, the facts the descriptions lack, and one example. The note on relaunching a session said that it applies after a server restart. session_start relaunches only a session whose client has exited or was stopped, so the note now says that. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 311 +++++++++----------------------------------------- 1 file changed, 57 insertions(+), 254 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index a37f9975..ff3ed944 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -3,17 +3,11 @@ This module runs rsprox without its GUI and serves it to a coding agent as an [MCP](https://modelcontextprotocol.io) server. The agent launches a RuneLite client through the proxy, reads the decoded packets in both directions, and looks at and drives the client. It is meant for -checking a private server end to end with a real client. +checking a private server end to end with a real client. The same endpoint also runs inside the rsprox +GUI, where it attaches to the clients that you launch by hand. -The same endpoint also runs inside the rsprox GUI. There it attaches to the clients that you launch -by hand, so an agent can read the packets of your own session while you play. See "Run it inside the -GUI". - -The server has two halves. The proxy half runs in this process and records every packet. The client -half is a small RuneLite plugin that the server installs into the client's sideload directory before -each launch. The plugin connects back to the server over loopback. The proxy only observes, so it -cannot inject or change packets. All input goes through the client as mouse and key events, so the -server receives what it receives from a player. +The proxy only observes, so it cannot inject or change packets. All input goes through the client as +mouse and key events, from a small RuneLite plugin that the server installs before each launch. ## Run it @@ -23,15 +17,8 @@ server receives what it receives from a player. The server listens on `http://127.0.0.1:43580/mcp`. It uses the proxy targets that the rsprox GUI uses, from `~/.rsprox/proxy-targets.yaml`. A session records to the usual folder of its target, as it -does when launched from the GUI. - -The GUI serves the same endpoint on the same port, so only one of the two can hold it. To run this -server next to a GUI, turn the GUI's endpoint off first, as described under "Run it inside the GUI". -Giving this server another port with `--port` is not enough, because both would write the same -rendezvous file. - -Each client opens a window, so the machine needs a display. A machine without one can use a virtual -display, as described under "Run it without a display". +does when launched from the GUI. Each client opens a window, so the machine needs a display or a +virtual one, as described under "Run it without a display". Options go in `--args`: @@ -49,9 +36,11 @@ Options go in `--args`: The plugin is installed as `rsprox-mcp-bridge.jar` in `~/.rlcustom/sideloaded-plugins` for a custom target and in `~/.runelite/sideloaded-plugins` for the official one. A client that the MCP server did -not launch loads the plugin too, and the plugin then does nothing. The plugin finds the server through -the rendezvous file `~/.rsprox/mcp/bridge.json`. Every server of one user account writes that same -file, so run one MCP server per user account. +not launch loads the plugin too, and the plugin then does nothing. + +Run one MCP endpoint per user account. The plugin finds its server through the file +`~/.rsprox/mcp/bridge.json`, which every endpoint of the account writes, so another `--port` is not +enough. To run this server next to a GUI, turn the GUI's endpoint off first. ## Run it inside the GUI @@ -72,16 +61,14 @@ When the port is taken, by a second rsprox or by the standalone server, the GUI says so and runs without the endpoint. Every RuneLite or native client that you launch from the GUI appears in `session_list` as a session -with `"kind":"attached"`. Sessions are numbered in the order they appear, `s1`, `s2` and so on, -together with the sessions that `session_start` launches. An agent picks the client you mean by the -`target`, the `proxyPort` and, once you are logged in, the `world`, the display `name` and the other -values under `login`. The proxy never sees a login name or a password, so neither is listed. -RuneScape 3 clients are not attached. +with `"kind":"attached"`, numbered together with the sessions that `session_start` launches. An agent +picks the client you mean by the `target`, the `proxyPort` and, once you are logged in, the values +under `login`, such as the `world` and the display `name`. The proxy never sees a login name or a +password, so neither is listed. RuneScape 3 clients are not attached. An attached session is read-only: -- `packets_read` works on it as on any session, with the same cursors and the same `L` number - for each login. +- `packets_read` works on it as on any session. - `session_stop`, `session_start` and every `client_*` tool refuse it with `not available for an attached session`. The client is yours, so the server never stops it, clicks in it or reads its screen. @@ -99,30 +86,20 @@ private chat, for as long as the GUI runs. Turn `mcp.enabled` off when that is n ## Run it without a display -RuneLite always opens a window, so on a machine with no screen give it a virtual one. macOS and -Windows have no virtual display, so there the client needs a real screen. On Linux, run the server -under Xvfb and pass `--software-rendering`: +On Linux, run the server under Xvfb and pass `--software-rendering`: ``` xvfb-run -s "-screen 0 1280x800x24" ./gradlew :mcp:run --args="--software-rendering" ``` Without `--software-rendering` everything works except `client_screenshot`, which returns a black -image. The GPU plugin renders with a software OpenGL driver on a virtual display, and the frames read -back from it are empty. The option stops the GPU plugin in each client the server launches. It does -not change the RuneLite profile, so clients started any other way keep their GPU setting. - -Besides a JDK, the machine needs Xvfb and the X11 and font libraries that Java's AWT loads. On Debian -or Ubuntu: +image. Besides a JDK, the machine needs Xvfb and the X11 and font libraries that Java's AWT loads. On +Debian or Ubuntu: ``` apt-get install xvfb libxrender1 libxtst6 libxi6 libxext6 libfontconfig1 libfreetype6 fonts-dejavu-core ``` -The machine needs no sound device. Without one the client reports that audio is unavailable and -carries on. It needs no loopback alias either. A custom target reaches each world through a loopback -address such as `127.0.1.3`, and Linux routes all of `127.0.0.0/8` to loopback. - ## Register it with an MCP client The transport is plain HTTP. With Claude Code: @@ -131,29 +108,12 @@ The transport is plain HTTP. With Claude Code: claude mcp add --transport http rsprox http://127.0.0.1:43580/mcp ``` -Any other client takes the same URL in its configuration for an HTTP server: - -```json -{ "mcpServers": { "rsprox": { "type": "http", "url": "http://127.0.0.1:43580/mcp" } } } -``` - -Start the server before the client connects. The server keeps its sessions and packets for as long as -it runs, whatever the clients do. +Any other client takes the same URL in its configuration for an HTTP server. Start the server before +the client connects. The server keeps its sessions and packets for as long as it runs, whatever the +clients do. ## Tools -The tools come in three groups. The `session_*` tools launch a client for a target, stop it and list -the sessions. `session_list` tells the sessions apart: each has its id, its `kind` (`launched` or -`attached`), its target, its state and its proxy port. Once the client has logged in, `login` holds -the login number, the revision, the world and its host, the index of the local player, the time of -the login and whether the client is still logged in. The capture file, the display name and the -newest tick are added when the proxy learns them. `session_start` gives the launcher of a client 180 seconds to complete its handshake. -When the launcher takes longer, the server abandons the launch and refuses further launches until it -is restarted. `packets_read` reads the decoded packets of a session after a cursor, and can wait for -the first packet that matches. The `client_*` tools look at the client and drive it: they read its -state, widgets and variables, take a screenshot, log in, click and type, list what is near the player -and act on it. - The server describes each tool itself. `tools/list` returns every tool with its description and the schema of its arguments: @@ -161,210 +121,53 @@ schema of its arguments: curl -s http://127.0.0.1:43580/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` -Each packet is one line: ` L T `. `C` is client to server, `S` -is server to client, and `P` is a marker that rsprox adds (`CLIENT_LAUNCHED`, `CLIENT_CONNECTED`, -`CLIENT_ATTACHED`, `CLIENT_EXITED`, `LOGIN`, `LOGOUT`). The key codes of typed text are zeroed in the recorded packets, as -in every rsprox recording. Read what was typed from the packet that carries the text, such as -`RESUME_P_STRINGDIALOG`. - -## Act on the game world - -Use `client_entities` and `client_interact` for anything in the game world: NPCs, objects, ground -items, other players, walking and dialogs. `client_entities` lists what is near the local player, -nearest first, with the options that each thing offers. Its coordinates are world coordinates, the -ones that the packets carry. An object that covers several tiles is listed once, at its south-west -tile. - -`client_interact` performs one of the listed options, or `Examine` on an NPC, an object or a ground -item, the way a player does. It finds the target in the client's own lists, moves the real mouse onto -it, lets the client build the menu for what is under the mouse, and clicks: a left click when the -option looks like the default one, otherwise a right click and a click on the option's row. The -server receives exactly what a player's interaction sends, mouse packets included. The client decides -what a click means, so a target that moves after the mouse was aimed would get the wrong click. The -plugin sees the click the client is about to perform, cancels it before anything is sent when it is -not the intended one, and aims again, within the game tick. Only the client knows which option a left -click performs, because it sorts its menu late and a plugin such as the menu entry swapper can -reorder it. When a left click turns out to perform another option, the plugin uses the right-click -menu for the rest of the call and says so in `tried`. When no part of the target is on screen it -turns the camera toward the target and keeps trying for up to six seconds. - -Only one call drives the mouse and the keyboard at a time. `client_interact`, `client_click`, -`client_type` and a `client_camera` call that turns the camera take turns, and the calls that only -read run beside them. - -The call first checks that the client has the target and that the target offers the option, and -fails with `not_found` when it does not. It then waits for the packet that the client sends and -returns its line as `packet`, with `attempts`, the number of aims it took, `tick`, the client's tick -count when the click went through, `tried`, the last attempts in words, and `camera` when it turned -the camera. `proof` says how the packet was tied to this call. Packets reach the log up to a tick -late, so a packet that an earlier action caused can arrive after the call began. The server therefore -first finds the client's own `EVENT_MOUSE_CLICK` record of the press, by its canvas coordinates, and -takes the first matching packet after it. `proof` is then `click`. When no such record arrives within -three seconds, it takes the first matching packet after the cursor, and `proof` is `prefix`. - -``` -1. session_start {"target":"My Server"} - -> {"session":"s1","kind":"launched","target":"My Server","state":"connected", ...} - -2. client_login {"username":"mcptest","password":"any"} - -> {"gameState":"LOGGED_IN","cursor":2} - -3. client_entities {"kinds":["npc"],"name":"guide"} - -> {"origin":{"x":3094,"y":3107,"plane":0},"npcs":[{"index":0,"id":3308,"name":"Gielinor Guide", - "x":3094,"y":3109,"options":["Talk-to"]}],"objects":[],"ground_items":[],"players":[], - "truncated":false,"cursor":612} - -4. client_interact {"target":"npc","index":0,"option":"Talk-to"} - -> {"target":"npc","index":0,"id":3308,"name":"Gielinor Guide","option":"Talk-to", - "attempts":1,"tick":41,"tried":["left-clicked Talk-to Gielinor Guide at (312, 171); the client - performed Talk-to Gielinor Guide"],"cursor":613, - "packet":"617 L1 T41 C OPNPC1_V2 [opnpc1_v2] npc=(index=0, ...)","proof":"click"} -``` - -The `packet` in step 4 is the proof that the client sent the action. The two packets before it are -the mouse click and the mouse movement that the client sends for any click. When no such packet -arrives within three seconds for a target in the game world, the call fails, because the client -dropped the action without a word. The error names the likely cause, such as an NPC or a player that -left the client's view. A widget is different, because the client handles some widget options by -itself and sends nothing for them. The call then succeeds with `"packet":null` and a `note` that says -so. A login that the proxy has no decoder for has no packets in the log at all, so every interaction -of that login returns `"packet":null` with a `note` that it could not be confirmed. What the server -answered is in `packets_read {"after":613}`. - -Walking is the same interaction with the option `Walk here` on a tile: -`client_interact {"target":"tile","x":3096,"y":3107}` clicks the tile and is proven by the -`MOVE_GAMECLICK` packet for it. A dialog is driven through its widgets: -`{"target":"dialog","option":"continue"}` clicks the widget that continues it, and -`{"target":"dialog","option":"2"}` or `{"target":"dialog","option":"Yes"}` clicks a choice by its -number or its text. Both are proven by `RESUME_PAUSEBUTTON`. A button of an interface is proven by its -`IF_BUTTON` packet, and a close button by `CLOSE_MODAL`. - -When the client never offers the option within the deadline, about three ticks, the call fails with -`wrong_state` and says what the client offered at the last attempt instead, such as `the client -offered [Walk here, Examine Tree]`. - -The client offers only Cancel at a point that an interface covers, such as the chatbox, the minimap -or the inventory. The tool never clicks there. After a few such points it turns the camera, which -moves the target to another part of the screen. If that does not help, the call fails with `the -client offers only Cancel; an interface covers the game view there`. An interface that blocks the -whole game view, such as the bank, gives the same message; close it first. - -A target that is hidden from every angle, behind a wall for instance, cannot be clicked, and neither -can anything in a secondary world view such as a boat: both tools read only the top-level world -view, on the plane of the local player. - -`client_camera` turns the camera on purpose: pass `yaw` and `pitch` in the client's units, or -`look_at` with the target fields of `client_interact`, and it returns the settled `yaw` and `pitch` -and whether the target is on screen. With no arguments it reads them. - -`client_click` is the unguarded low-level fallback: it clicks a canvas position or the centre of a -widget, and the client performs whatever is under the mouse at that moment. Use it for positions on an -interface that `client_interact` cannot name, and `client_type` for typed input. +The descriptions leave out the following: + +- `session_start` gives the launcher of a client 180 seconds to complete its handshake. When the + launcher takes longer, the server abandons the launch and refuses further launches until it is + restarted. +- The key codes of typed text are zeroed in the recorded packets, as in every rsprox recording. Read + what was typed from the packet that carries the text, such as `RESUME_P_STRINGDIALOG`. +- Only one call drives the mouse and the keyboard of a client at a time. `client_interact`, + `client_click`, `client_type` and a `client_camera` call that turns the camera take turns. The + calls that only read run beside them. +- The client offers only Cancel at a point that an interface covers, such as the chatbox or the + minimap. `client_interact` never clicks there. It turns the camera after a few such points, and + then fails with `the client offers only Cancel; an interface covers the game view there`. An + interface that blocks the whole game view, such as the bank, gives the same message. Close it first. +- A target that is hidden from every angle, behind a wall for instance, cannot be clicked. Neither + can anything in a secondary world view such as a boat: `client_entities` and `client_interact` + read only the top-level world view. ## Example: check that setting a display name works -The display-name interface is group 558. Its name field is widget `558:12`. `558:12` is the text -shown in the field, and it sits on top of `558:7`, the component that receives the click, so the -packet in step 5 reports `558:7`. +The display-name interface is group 558, and its name field is widget `558:12`. ``` 1. session_start {"target":"My Server"} - -> {"session":"s1","kind":"launched","target":"My Server","state":"connected","generation":1, - "proxyPort":43751,"httpPort":43650,"pid":40388,"cursor":2} - -2. client_login {"username":"mcptest","password":"any"} + -> {"session":"s1","kind":"launched","target":"My Server","state":"connected", ...,"cursor":2} +2. client_login {"username":"alice","password":"any"} -> {"gameState":"LOGGED_IN","cursor":2} - -3. client_widgets {"group":558} - -> {"roots":[...],"widgets":[..., {"id":"558:12","text":"*","bounds":[467,350,332,22], - "click":[633,361], ...}, ...],"truncated":false,"cursor":470} - -4. client_click {"widget":"558:12"} +3. client_click {"widget":"558:12"} -> {"x":633,"y":361,"cursor":471} - -5. packets_read {"after":471,"prots":["IF_BUTTONX"],"wait_ms":3000} - -> {"next":473,"head":473,"dropped":0,"timedOut":false,"count":1} - 473 L1 T5 C IF_BUTTONX [if_buttonx] com=tutorial_displayname:name (558:7), op=1 - -6. client_type {"text":"McpProto","enter":true} - -> {"typed":8,"cursor":484} - -7. packets_read {"after":484,"prots":["IF_SETTEXT"],"contains":"McpProto","wait_ms":5000} +4. client_type {"text":"Alice","enter":true} + -> {"typed":5,"cursor":484} +5. packets_read {"after":484,"prots":["IF_SETTEXT"],"contains":"Alice","wait_ms":5000} -> {"next":519,"head":519,"dropped":0,"timedOut":false,"count":1} 519 L1 T8 S IF_SETTEXT [if_settext] com=tutorial_displayname:status (558:13), text="Great! The - display name McpProto is available!
You may set this - name now, or enter another to look up." - -8. client_widgets {"group":558,"text":"available"} - -> {"roots":[...],"widgets":[{"id":"558:13","text":"Great! The display name McpProto - is available!
You may set this name now, or enter another to look up.", ...}], - "truncated":false,"cursor":520} - -9. session_stop {} - -> {"session":"s1","kind":"launched","target":"My Server","state":"stopped", - "reason":"stopped by caller", ...} + display name Alice is available! ..." ``` -Steps 5, 7 and 8 are three separate proofs. The client sent the click. The server answered the name -the client sent. The client rendered the answer. +Step 5 proves that the server answered the name the client sent. The same wait proves that nothing +happened. Wait for the packet and expect a timeout: +`packets_read {"after":519,"prots":["IF_SETTEXT"],"wait_ms":1800}` returning `"timedOut":true` means +the server sent no such packet for three ticks. -A wait returns as soon as one packet matches. The name itself left the client two ticks earlier, as -`RESUME_P_STRINGDIALOG [resume_p_stringdialog] string="McpProto"`, and -`packets_read {"after":484,"origin":"client"}` shows it. +When the client of a session has exited or was stopped, call `session_start {"session":"s1"}`. The +session keeps its id and its packet log. The client comes back on new ports, and the packets of its +next login are marked with the next login number. -The same wait proves that nothing happened. Wait for the packet and expect a timeout: -`packets_read {"after":520,"prots":["IF_SETTEXT"],"wait_ms":1800}` returning `"timedOut":true` means -the server sent no such packet for three ticks. +## What the automated tests do not cover -After a server restart, call `session_start {"session":"s1"}`. The session keeps its id and its packet -log. The client comes back on new ports, and new packets are marked with the next login number. - -## Check it against a live client - -The automated tests cover the packet log, the endpoint's HTTP and JSON-RPC handling, the tool calls -as an MCP client makes them, the session failure paths, the attached sessions and the bridge handshake. They do not run a -game client, so what the client does is checked by hand. Run this after a RuneLite update, a game -revision change, or a change to the plugin. Each step names what proves it, so a step that fails -names what changed. A tool that reports a dropped action, or lists what the client offered instead, -is working as intended. Read its message before suspecting the tool. - -Start the server and a session against a target you can log in to, with a new account: - -1. `session_start` returns `"state":"connected"` with a `pid`, and that pid is the client process. -2. `client_login` returns `"gameState":"LOGGED_IN"`, and `packets_read {"origin":"proxy"}` shows a - `LOGIN` row. `packets_read {"limit":20}` shows packets of tick 0, with named values. -3. `client_screenshot` returns an image whose width and height equal `canvas` in `client_state`. -4. `client_interact` on an interface button returns a `packet` line and `"proof":"click"`. - `packets_read` from the returned cursor shows an `EVENT_MOUSE_CLICK` row before the button packet, - so the click was a real one. -5. `client_type` into a prompt is followed by a packet that carries the typed text. -6. `client_entities` lists the NPCs and objects that the screenshot shows, with their options. -7. `client_interact` with the default option of an NPC that walks around returns its `OPNPC` packet. - The `attempts` count stays low. Repeat it a few times. -8. `client_interact` with an option that is not the default, such as `Examine`, goes through the - right-click menu: the last entry of `tried` starts with `clicked row`. -9. `client_interact` on an object and on a tile each return their packet, and after the walk - `client_state` shows the player on that tile. -10. `client_interact {"target":"dialog","option":"continue"}` advances a conversation. -11. With an interface open that blocks the game view, `client_interact` on an NPC fails and says that - the client offers only Cancel. -12. `client_camera {"look_at":"npc","index":0}` returns `"onScreen":true`, and the next screenshot - shows the turned view. -13. Killing the client process makes `session_list` show `stopped` with the reason `client exited`, - and `session_start` with the same `session` brings it back with the earlier packets still readable. - -Then check the attached sessions. Stop the standalone server, start the GUI with `./gradlew proxy` -and launch a client from it by hand: - -1. `session_list` shows a session with `"kind":"attached"`, the target and the proxy port of the - client, and no `login`. -2. After you log in, `session_list` shows `login` with the world, the capture file and, a tick or two - later, your display name and a `tick` that advances. `packets_read` on the session returns the - packets that the GUI's own panel shows. -3. The GUI's panel shows the same packets as it does with `mcp.enabled=false`. -4. `session_stop` and `client_state` on the session fail with `not available for an attached - session`, and the client keeps running. -5. Closing the client window makes `session_list` show `"state":"ended"`. So does closing the tab of - a second client. Both sessions stay listed with their packets. -6. With the standalone server running first, the GUI starts as usual and logs that the endpoint is - not served. +The tests run no game client. Check what the plugin does inside RuneLite by hand against a live +client, after a RuneLite update, a game revision change or a change to the plugin. From 512ff1035728596705c454660de178c02556e328 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 11:41:09 +0200 Subject: [PATCH 53/72] docs(mcp): say when the capture file of a login first exists session_list names the file from the moment of the login, but the proxy only writes it at its write interval or at the logout. An agent that looked for the file straight away found nothing. Co-Authored-By: Claude Opus 5.5 --- mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt | 5 +++-- mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index b051f52a..fbd909f7 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -71,8 +71,9 @@ public fun tools(sessions: () -> SessionManager): List = "`epoch` (the L number of its packets), `revision`, `world`, `host`, `localPlayerIndex`, " + "`connectedAt`, `online` (whether it is still logged in) and `transcribing` (whether its " + "packets are decoded). `captureFile`, the recording under the rsprox binary directory, is " + - "absent for a target that is not recorded. `name`, the display name, and `tick`, the " + - "newest server tick seen, are absent until known.", + "absent for a target that is not recorded; rsprox first writes the file some minutes " + + "into the login, or at its logout. `name`, the display name, and `tick`, the newest " + + "server tick seen, are absent until known.", inputSchema = schema(), ) { val manager = sessions() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 60fd6d09..6b2ff73b 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -79,8 +79,8 @@ public data class LoginInfo( /** The moment the server accepted the login, in ISO-8601. */ val connectedAt: String, /** - * The file the login is recorded to, relative to the `binary` directory of rsprox. - * Null for a target that is not recorded. + * The file the login is recorded to, relative to the `binary` directory of rsprox. The proxy first + * writes it at its write interval or at the logout. Null for a target that is not recorded. */ val captureFile: String?, /** The display name of the player, or null until the proxy reports it. */ From 37c3fedf2dfdf79f16941d77c59e3715e714ebca Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:18:52 +0200 Subject: [PATCH 54/72] feat(mcp): refuse the tools that send input on the official game A session has an access, read or drive, and every client tool declares the access it needs where it is defined. A launched session of target 0, or of any target whose jav_config comes from a Jagex domain, is read-only, and so is an attached session. A tool that sends input is refused there before anything reaches the client, and the refusal names the tools that do work. Below the tool check, the link the hub makes for a read-only session refuses such a call itself, so a bypassed tool check still forwards nothing. client_camera reads with no turn arguments and sends input with them, which the plugin will see as the ops camera and camera_turn. session_list shows the access of each session. Co-Authored-By: Claude Fable 5.1 --- .../kotlin/net/rsprox/mcp/bridge/Access.kt | 16 ++ .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 5 +- .../net/rsprox/mcp/bridge/BridgeLink.kt | 17 +- .../kotlin/net/rsprox/mcp/server/Tools.kt | 81 +++++++-- .../kotlin/net/rsprox/mcp/session/Session.kt | 81 ++++++++- .../net/rsprox/mcp/session/SessionManager.kt | 8 +- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 23 ++- .../net/rsprox/mcp/bridge/FakePlugin.kt | 1 + .../rsprox/mcp/server/AttachedSessionTest.kt | 8 +- .../rsprox/mcp/server/ReadOnlySessionTest.kt | 157 ++++++++++++++++++ .../net/rsprox/mcp/session/SessionFixtures.kt | 2 +- .../mcp/session/SessionManagerRecoveryTest.kt | 6 +- 12 files changed, 369 insertions(+), 36 deletions(-) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/bridge/Access.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/ReadOnlySessionTest.kt diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Access.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Access.kt new file mode 100644 index 00000000..0ad24a50 --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Access.kt @@ -0,0 +1,16 @@ +package net.rsprox.mcp.bridge + +/** + * What may be done with a client: read it, or also drive it by sending it input. The order matters: + * [DRIVE] includes [READ], so an access is enough for a call when it is at least what the call needs. + */ +public enum class Access( + /** The name on the wire: in the welcome the hub sends and in the `access` of a session. */ + public val wire: String, +) { + /** Read the client: its state, widgets, variables, entities, a screenshot and where the camera points. */ + READ("read"), + + /** Read the client and send it input: clicks, keys, a login and camera turns. */ + DRIVE("drive"), +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index 97e2bee7..22ad86ca 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -23,6 +23,9 @@ internal interface BridgeListener { /** The session id, told to the plugin for its log. */ val session: String + /** The most the session may do with the client, told to the plugin and bound into its link. */ + val access: Access + /** * Take the link of the client that said hello for this session. * Returns false when the session no longer waits for this client; the hub then drops the link. @@ -174,7 +177,7 @@ public class BridgeHub( socket.soTimeout = 0 val link = - BridgeLink(socket, reader, writer) { closed -> + BridgeLink(listener.access, socket, reader, writer) { closed -> links.remove(closed) listener.onClosed(closed) } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt index f9f4e9bc..623c48a2 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -31,9 +31,12 @@ public class BridgeError( * One connected in-client bridge. Compared by identity, so a late event from an old link cannot match a new one. * * The wire carries one JSON object per line in each direction. Requests may overlap; each reply is - * matched to its caller by id. + * matched to its caller by id. A link made for reading never forwards a request that sends input, + * whatever the caller asks. */ public class BridgeLink internal constructor( + /** The most that the plugin behind the link was told it may do, which bounds every call. */ + public val access: Access, /** The connection to the plugin. */ private val socket: Closeable, /** The source of the plugin's replies. */ @@ -60,17 +63,23 @@ public class BridgeLink internal constructor( } /** - * Send one request and block the calling thread for its reply. + * Send one request, which needs [access], and block the calling thread for its reply. * * @return the `ok` value of the reply - * @throws BridgeError when the plugin answers `err`, when no reply arrives within [timeoutMs] - * (code `timeout`), or when the link is or becomes closed (code `closed`) + * @throws BridgeError when the request needs more than this link's access (code `read_only`), when + * the plugin answers `err`, when no reply arrives within [timeoutMs] (code `timeout`), or when the + * link is or becomes closed (code `closed`) */ public fun call( op: String, args: ObjectNode, + access: Access, timeoutMs: Long, ): JsonNode { + if (access > this.access) { + throw BridgeError("read_only", "'$op' sends input, which a link made for reading never forwards") + } + val id = ids.incrementAndGet() val reply = CompletableFuture() pending[id] = reply diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index fbd909f7..7cb59cdb 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -2,6 +2,7 @@ package net.rsprox.mcp.server import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode +import net.rsprox.mcp.bridge.Access import net.rsprox.mcp.bridge.BridgeError import net.rsprox.mcp.packets.Cursor import net.rsprox.mcp.packets.Origin @@ -24,7 +25,9 @@ public fun tools(sessions: () -> SessionManager): List = "Blocks until the in-client bridge connects and then reports `state` as `connected`, " + "after which the client_* tools work. If `wait_ms` elapses first, `state` is `launching`; " + "call it again with the same `session` to keep waiting. With no arguments it starts a new " + - "session on the first custom target. Pass `session` to relaunch a stopped session on fresh " + + "session on the first custom target. A session of the official game launches too, but is " + + "read-only: the server never sends input to the live game, so the person at the keyboard " + + "logs in. Pass `session` to relaunch a stopped session on fresh " + "ports; its packet log and cursor continue. Calling it for a session that is already " + "running launches nothing. An attached session cannot be started.", inputSchema = @@ -63,7 +66,10 @@ public fun tools(sessions: () -> SessionManager): List = "List every session, plus the target names. `kind` is `launched` for a client that " + "session_start launched, and `attached` for a client that was launched by hand from the " + "rsprox GUI, which only packets_read works on. Each session has `session`, `target`, " + - "`state`, `proxyPort` and `cursor`, the newest packet cursor. A launched session with a " + + "`access`, `state`, `proxyPort` and `cursor`, the newest packet cursor. `access` is `drive` " + + "when the client_* tools that send input may be used on the session, and `read` when only " + + "the tools that read may: a session of the official game is read-only, and so is an " + + "attached one. A launched session with a " + "client also has `generation`, the number of its launch, and `httpPort`, and `pid` once " + "connected. `state` is `stopped`, " + "`launching` or `connected` when launched, and `attached` or `ended` when attached; " + @@ -119,6 +125,7 @@ public fun tools(sessions: () -> SessionManager): List = }, clientTool( name = "client_state", + access = Access.READ, description = "Read what the client is doing: `gameState` (such as LOGIN_SCREEN or LOGGED_IN), `tick`, " + "`canvas` as [width, height], `world`, `player` with its name and tile (null unless logged " + @@ -128,6 +135,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_screenshot", + access = Access.READ, description = "Capture the game canvas as a PNG. The image has the size of the canvas, so a pixel " + "position in it is the `x`,`y` to pass to client_click. The text block holds `width` " + @@ -138,6 +146,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_widgets", + access = Access.READ, description = "List the interface widgets that have text, a name or options. Each has `id` " + "(\":\", or \":[]\" for a dynamic child), `text`, `name`, " + @@ -158,6 +167,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_vars", + access = Access.READ, description = "Read client variables by id. The result maps each requested id to its value under " + "`varps`, `varbits`, `varcInts` and `varcStrs`.", @@ -173,6 +183,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_login", + access = Access.DRIVE, description = "Log in with the given credentials and wait until the client is in the game. A client that is " + "still starting is given until `wait_ms` to reach the login screen first. " + @@ -195,6 +206,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_click", + access = Access.DRIVE, description = "Click on the game canvas, either at `x`,`y` in canvas units (the pixels of a " + "client_screenshot) or at the centre of `widget`. This is the low-level fallback: prefer " + @@ -214,6 +226,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_type", + access = Access.DRIVE, description = "Type text into the client as key presses, as if on the keyboard. Returns `typed`, the " + "number of characters sent.", @@ -228,6 +241,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_entities", + access = Access.READ, description = "List what is near the local player on its plane, nearest first: `npcs`, `objects`, " + "`ground_items` and `players`, each with `name`, its tile `x`,`y` in world coordinates and " + @@ -251,6 +265,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_interact", + access = Access.DRIVE, description = "Perform one option on one thing in the game with a real mouse click, and confirm it in " + "the packet log. It aims by identity, cancels a click that the client resolves to " + @@ -289,6 +304,7 @@ public fun tools(sessions: () -> SessionManager): List = ), clientTool( name = "client_camera", + request = ::cameraRequest, description = "Read or turn the camera. With no arguments it returns the current `yaw` and `pitch`. " + "Pass `yaw` and/or `pitch`, or `look_at` with the same target fields as client_interact, " + @@ -313,11 +329,39 @@ public fun tools(sessions: () -> SessionManager): List = ), ) +/** One request to the plugin: the op to run, and the access that running it needs. */ +private class Request( + /** The name of the op in the plugin. */ + val op: String, + /** [Access.DRIVE] when the op sends the client input, [Access.READ] when it only reads. */ + val access: Access, +) + +/** + * Build a client_* tool with one fixed [access], which forwards its arguments to the op of the same + * name in the plugin. The description says whether the tool sends input, and so is refused on a read-only session. + */ +private fun clientTool( + name: String, + description: String, + schema: ObjectNode, + sessions: () -> SessionManager, + access: Access, + defaults: Map = emptyMap(), + timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, + result: (ok: ObjectNode, session: Session) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, +): Tool { + val note = if (access == Access.DRIVE) DRIVE_NOTE else READ_NOTE + val request = Request(name.removePrefix("client_"), access) + + return clientTool(name, "$description $note", schema, sessions, { request }, defaults, timeoutMs, result) +} + /** - * Build a client_* tool, which forwards its arguments to the op of the same name in the plugin. - * The plugin's answer is the tool result, plus the packet cursor - * taken before the call, so everything the action caused has a sequence number above it. - * The session decides whether the tool may reach its client, and refuses it for an attached session. + * Build a client_* tool, which forwards its arguments to the plugin as the [request] that the arguments + * call for. The plugin's answer is the tool result, plus the packet cursor taken before the call, so + * everything the action caused has a sequence number above it. The session decides whether a request + * with that access may reach its client, and refuses every request for an attached session. * * The plugin has no defaults of its own: each of [defaults] is forwarded when the caller left it out. */ @@ -326,13 +370,15 @@ private fun clientTool( description: String, schema: ObjectNode, sessions: () -> SessionManager, + request: (ObjectNode) -> Request, defaults: Map = emptyMap(), timeoutMs: (ObjectNode) -> Long = { CLIENT_CALL_TIMEOUT_MS }, result: (ok: ObjectNode, session: Session) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, ): Tool = - Tool(name, "$description $CLIENT_TOOL_NOTE", schema) { args -> + Tool(name, "$description $CURSOR_NOTE", schema) { args -> val session = sessions().resolve(args.text("session")) - val link = session.link(name) + val requested = request(args) + val link = session.link(name, requested.access) val cursor = session.packets.head().seq val forwarded = args.deepCopy().without("session") @@ -340,12 +386,19 @@ private fun clientTool( if (!forwarded.hasNonNull(argument)) forwarded.put(argument, value) } - val ok = link.call(name.removePrefix("client_"), forwarded, timeoutMs(forwarded)) + val ok = link.call(requested.op, forwarded, requested.access, timeoutMs(forwarded)) if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") result(ok.put("cursor", cursor), session) } +/** The camera request the arguments call for: a turn, which sends input, when any turn argument is given. */ +private fun cameraRequest(args: ObjectNode): Request = + if (CAMERA_TURN_ARGUMENTS.any(args::hasNonNull)) Request("camera_turn", Access.DRIVE) else Request("camera", Access.READ) + +/** The arguments of client_camera that ask for a turn. */ +private val CAMERA_TURN_ARGUMENTS = listOf("yaw", "pitch", "look_at") + /** The longest wait for the plugin to answer a call, on top of any wait the call itself asks for. */ private const val CLIENT_CALL_TIMEOUT_MS = 10_000L @@ -374,10 +427,18 @@ private const val ENTITY_LIMIT = 100L private const val SENT_WAIT_MS = 3_000L /** The sentences that end the description of every client_* tool. */ -private const val CLIENT_TOOL_NOTE = +private const val CURSOR_NOTE = "The result carries `cursor`, the packet cursor taken just before the call: pass it as `after` to " + "packets_read to see only the packets from this call onwards. Not available for an attached session." +/** The sentence in the description of a client_* tool that only reads. */ +private const val READ_NOTE = "Reads only, so it works on a read-only session." + +/** The sentence in the description of a client_* tool that sends input. */ +private const val DRIVE_NOTE = + "Sends input to the client, so it is refused on a read-only session: one whose target is the official " + + "game, which the server never drives." + /** The schema of the `session` argument that every tool but session_start and session_list takes. */ private val SESSION: ObjectNode = string("Session id, such as \"s1\". May be omitted while only one session exists.") diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 6b2ff73b..d064841a 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -1,17 +1,49 @@ package net.rsprox.mcp.session import com.fasterxml.jackson.annotation.JsonInclude +import net.rsprox.mcp.bridge.Access import net.rsprox.mcp.bridge.BridgeLink import net.rsprox.mcp.packets.Origin import net.rsprox.mcp.packets.PacketLog import net.rsprox.mcp.server.ToolError import net.rsprox.proxy.binary.BinaryHeader import net.rsprox.proxy.target.ProxyTargetConfig +import java.net.URI +import java.net.URISyntaxException import java.time.Instant import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock +/** + * What the MCP server may do with a client of the target. A client of the official game is only read. + * That is target 0, and also any target that takes its jav_config from Jagex, since such a client plays + * the live game whatever its id. Target 0 with an overridden jav_config is read-only too, which errs on + * the safe side. + */ +internal val ProxyTargetConfig.access: Access + get() = if (id == 0 || isJagexHost(javConfigUrl)) Access.READ else Access.DRIVE + +/** Determine if the URL is served from a domain of Jagex, which only the official game is configured from. */ +private fun isJagexHost(url: String): Boolean { + val host = + try { + URI(url).host?.lowercase() ?: return false + } catch (e: URISyntaxException) { + return false + } + + return JAGEX_DOMAINS.any { host == it || host.endsWith(".$it") } +} + +/** The domains that the official game's jav_config is served from. */ +private val JAGEX_DOMAINS = setOf("runescape.com", "jagex.com") + +/** The sentence that tells an agent what works on a read-only session, for the refusal of a tool that does not. */ +internal const val READ_TOOLS: String = + "Use the tools that read: client_state, client_screenshot, client_widgets, client_vars, client_entities, " + + "client_camera without yaw, pitch or look_at, and packets_read." + /** Stable for the life of the MCP process. Survives client restarts. Never a port. */ @JvmInline public value class SessionId( @@ -166,6 +198,8 @@ public data class SessionSnapshot( val kind: String, /** The name of the proxy target the session belongs to. */ val target: String, + /** `drive` when the client tools that send input may be used on the session, `read` when only those that read may. */ + val access: String, /** The state: `stopped`, `launching` or `connected` when launched, `attached` or `ended` when attached. */ val state: String, /** The reason the session has no client, or null while it has one. */ @@ -201,11 +235,17 @@ public sealed class Session( /** The logins of the session. */ internal val logins = LoginRegistry() + /** The most that the client tools may do with the client of the session. */ + public abstract val access: Access + /** - * Get the link to the plugin in the client, for the client tool named [tool]. - * Throws a [ToolError] that says why the tool cannot reach the client. + * Get the link to the plugin in the client, for the client tool named [tool], which needs [access]. + * Throws a [ToolError] that says why the tool may not, or cannot, reach the client. */ - internal abstract fun link(tool: String): BridgeLink + internal abstract fun link( + tool: String, + access: Access, + ): BridgeLink /** Get what the session tools report of the session at this moment. */ public abstract fun snapshot(): SessionSnapshot @@ -233,6 +273,9 @@ public class LaunchedSession internal constructor( /** The number of launches so far. */ private var generations = 0 + /** What the target allows: a client of the official game is only read. */ + override val access: Access = target.access + /** The state of the client, which only [transition] changes. */ @Volatile public var client: ClientState = ClientState.Stopped("not started") @@ -315,15 +358,29 @@ public class LaunchedSession internal constructor( } } - /** Get the link of the connected client. Throws a [ToolError] that says what to do about its absence. */ - override fun link(tool: String): BridgeLink = - when (val state = client) { + /** + * Get the link of the connected client. Throws a [ToolError] that says why a tool that sends input + * is refused on the official game, or else what to do about the absence of a client. + */ + override fun link( + tool: String, + access: Access, + ): BridgeLink { + if (access > this.access) { + throw ToolError( + "$tool sends input to the client, which is refused for session $id: its target '${target.name}' " + + "is the official game, so the session is read-only. $READ_TOOLS", + ) + } + + return when (val state = client) { is ClientState.Connected -> state.link is ClientState.Launching -> throw ToolError("session $id is still launching; call session_start with this session to wait for it") is ClientState.Stopped -> throw ToolError("session $id has no connected client: ${state.reason}") } + } /** Get what the session tools report of the session at this moment. */ override fun snapshot(): SessionSnapshot { @@ -334,6 +391,7 @@ public class LaunchedSession internal constructor( session = id.value, kind = "launched", target = target.name, + access = access.wire, state = when (state) { is ClientState.Stopped -> "stopped" @@ -379,6 +437,9 @@ public class AttachedSession internal constructor( @Volatile private var ended: String? = null + /** Read only, since the client belongs to the person at the keyboard. */ + override val access: Access = Access.READ + init { mark("CLIENT_ATTACHED", "proxyPort=$proxyPort") } @@ -392,8 +453,11 @@ public class AttachedSession internal constructor( mark("CLIENT_EXITED", reason) } - /** Refuse the tool, since no client tool reaches the client of an attached session. */ - override fun link(tool: String): BridgeLink = throw ToolError(notAvailable(tool)) + /** Refuse the tool, since no client tool reaches the client of an attached session yet. */ + override fun link( + tool: String, + access: Access, + ): BridgeLink = throw ToolError(notAvailable(tool)) /** Build the refusal of a tool that acts on the client, for the error of that tool. */ internal fun notAvailable(tool: String): String = @@ -408,6 +472,7 @@ public class AttachedSession internal constructor( session = id.value, kind = "attached", target = target.name, + access = access.wire, state = if (reason == null) "attached" else "ended", reason = reason, generation = null, diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index 4d0c71c6..4e645da2 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -1,5 +1,6 @@ package net.rsprox.mcp.session +import net.rsprox.mcp.bridge.Access import net.rsprox.mcp.bridge.BridgeHub import net.rsprox.mcp.bridge.BridgeLink import net.rsprox.mcp.bridge.BridgeListener @@ -222,8 +223,8 @@ public class SessionManager internal constructor( val targets = launcher.targets() if (name == null) { - // Target 0 is the official game; a custom target is what a headless caller is here to test. - return targets.firstOrNull { it.id != 0 } ?: targets.first() + // A custom target, whose client can be driven, is what a headless caller is here to test. + return targets.firstOrNull { it.access == Access.DRIVE } ?: targets.first() } return targets.firstOrNull { it.name.equals(name, ignoreCase = true) } @@ -366,6 +367,9 @@ public class SessionManager internal constructor( /** The id of the session that waits for the client. */ override val session: String = session.id.value + /** What the session may do with the client, which its target decides. */ + override val access: Access = session.access + /** Connect the session to the link, unless the session has moved on from this launch. */ override fun onHello( link: BridgeLink, diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index a75857fa..c0f05219 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -80,7 +80,7 @@ class BridgeHubTest { @Test fun `a call sends the op and its args and returns the ok value`() { val (plugin, link) = connect() - val call = async { link.call("click", args("""{"x":380,"y":215}"""), TIMEOUT_MS) } + val call = async { link.call("click", args("""{"x":380,"y":215}"""), Access.DRIVE, TIMEOUT_MS) } val request = plugin.read()!! assertEquals("click", request.get("op").asText()) @@ -90,12 +90,27 @@ class BridgeHubTest { assertEquals(args("""{"x":380,"y":215}"""), call.get(10, TimeUnit.SECONDS)) } + @Test + fun `a link made for reading refuses an op that sends input and forwards the next op that reads`() { + val (plugin, link) = connect(RecordingListener(access = Access.READ)) + + val refused = + assertFailsWith { link.call("click", args("""{"x":1,"y":2}"""), Access.DRIVE, TIMEOUT_MS) } + assertEquals("read_only", refused.code) + + val call = async { link.call("state", args(), Access.READ, TIMEOUT_MS) } + val request = plugin.read()!! + assertEquals("state", request.get("op").asText()) + plugin.send("""{"id":${request.get("id")},"ok":{}}""") + call.get(10, TimeUnit.SECONDS) + } + @Test fun `an err reply becomes a bridge error with its code and message`() { val (plugin, link) = connect() plugin.serve { _, _ -> """"err":{"code":"not_found","message":"widget 558:7 is not visible"}""" } - val error = assertFailsWith { link.call("click", args("""{"widget":"558:7"}"""), TIMEOUT_MS) } + val error = assertFailsWith { link.call("click", args("""{"widget":"558:7"}"""), Access.DRIVE, TIMEOUT_MS) } assertEquals("not_found", error.code) assertEquals("widget 558:7 is not visible", error.message) } @@ -104,14 +119,14 @@ class BridgeHubTest { fun `a closed connection fails the pending calls, reports the close and refuses new calls`() { val listener = RecordingListener() val (plugin, link) = connect(listener) - val pending = async { link.call("screenshot", args(), TIMEOUT_MS) } + val pending = async { link.call("screenshot", args(), Access.READ, TIMEOUT_MS) } plugin.read() plugin.close() assertEquals("closed", bridgeError(pending).code) assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) - assertEquals("closed", assertFailsWith { link.call("state", args(), TIMEOUT_MS) }.code) + assertEquals("closed", assertFailsWith { link.call("state", args(), Access.READ, TIMEOUT_MS) }.code) } private companion object { diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index 22b01867..b9be9a8c 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -50,6 +50,7 @@ internal class TestHub : AutoCloseable { internal class RecordingListener( override val session: String = "s1", + override val access: Access = Access.DRIVE, ) : BridgeListener { val hello = CompletableFuture>() val closed = CompletableFuture() diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt index 32cbe084..8f9b229b 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -60,9 +60,9 @@ class AttachedSessionTest { assertEquals( listOf( - """{"session":"s1","kind":"launched","target":"My Server","state":"launching","generation":1,""" + + """{"session":"s1","kind":"launched","target":"My Server","access":"drive","state":"launching","generation":1,""" + """"proxyPort":43751,"httpPort":43650,"cursor":1}""", - """{"session":"s2","kind":"attached","target":"Local","state":"attached",""" + + """{"session":"s2","kind":"attached","target":"Local","access":"read","state":"attached",""" + """"proxyPort":43701,"cursor":1}""", ), sessions(), @@ -81,7 +81,7 @@ class AttachedSessionTest { @Test fun `the session list reports each fact of a login from the moment the proxy knows it`() { val client = attach() - val attached = """{"session":"s1","kind":"attached","target":"Local","state":"attached","proxyPort":43701""" + val attached = """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached","proxyPort":43701""" val accepted = """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + """"connectedAt":"2023-11-14T22:13:20Z"""" @@ -124,7 +124,7 @@ class AttachedSessionTest { assertEquals( listOf( - """{"session":"s1","kind":"attached","target":"Local","state":"attached","proxyPort":43701,""" + + """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached","proxyPort":43701,""" + """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + """"connectedAt":"2023-11-14T22:13:20Z","online":true,"transcribing":false},"cursor":2}""", ), diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ReadOnlySessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ReadOnlySessionTest.kt new file mode 100644 index 00000000..0cfe2c1e --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ReadOnlySessionTest.kt @@ -0,0 +1,157 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.bridge.Access +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.target +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class ReadOnlySessionTest { + private val server = ToolServer() + + // Every request a plugin received, as its session, its op and its arguments. + private val received = CopyOnWriteArrayList() + + /** One client tool with arguments that pass its schema, and the op the plugin runs for it. */ + private class Call( + val tool: String, + val access: Access, + val op: String, + val arguments: String = "{}", + ) + + private val calls = + listOf( + Call("client_state", Access.READ, "state"), + Call("client_screenshot", Access.READ, "screenshot"), + Call("client_widgets", Access.READ, "widgets"), + Call("client_vars", Access.READ, "vars"), + Call("client_entities", Access.READ, "entities"), + Call("client_camera", Access.READ, "camera"), + Call("client_login", Access.DRIVE, "login", """{"username":"alice","password":"any"}"""), + Call("client_click", Access.DRIVE, "click", """{"x":10,"y":20}"""), + Call("client_type", Access.DRIVE, "type", """{"text":"hello"}"""), + Call("client_interact", Access.DRIVE, "interact", """{"target":"tile","x":3094,"y":3107}"""), + Call("client_camera", Access.DRIVE, "camera_turn", """{"yaw":100}"""), + Call("client_camera", Access.DRIVE, "camera_turn", """{"pitch":2048}"""), + Call("client_camera", Access.DRIVE, "camera_turn", """{"look_at":"npc","index":0}"""), + ) + + @AfterTest + fun cleanUp() { + server.close() + } + + /** Launches a session of the target and connects a plugin that answers every op with an empty object. */ + private fun connect(target: String): String { + val session = server.callJson("session_start", """{"target":"$target","wait_ms":0}""").get("session").asText() + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT + server.launcher.reserved.size - 1) + plugin.read() + plugin.serve { op, args -> + received += "$session $op $args" + if (op == "interact") """"ok":{"expect":"MOVE_GAMECLICK","pressed":[1,2]}""" else """"ok":{"png":""}""" + } + + server.call("session_start", """{"session":"$session","wait_ms":10000}""") + + return session + } + + /** Calls a tool that must succeed, whatever content it returns. */ + private fun succeed(call: Call) { + val result = server.rpc("tools/call", """{"name":"${call.tool}","arguments":${call.arguments}}""") + + assertFalse(result.get("isError").asBoolean(), result.toString()) + } + + private fun refusal( + tool: String, + session: String, + ): String = + "$tool sends input to the client, which is refused for session $session: its target " + + "'Old School RuneScape' is the official game, so the session is read-only. Use the tools that read: " + + "client_state, client_screenshot, client_widgets, client_vars, client_entities, client_camera without " + + "yaw, pitch or look_at, and packets_read." + + @Test + fun `every client tool reaches a client of a custom target`() { + val session = connect("My Server") + + calls.forEach(::succeed) + + assertEquals(calls.map { "$session ${call(it)}" }, received) + } + + @Test + fun `on the official game the tools that read reach the client and the tools that send input are refused`() { + val session = connect("Old School RuneScape") + + for (call in calls.filter { it.access == Access.DRIVE }) { + assertEquals(refusal(call.tool, session), server.error(call.tool, call.arguments)) + } + + assertEquals(emptyList(), received) + + calls.filter { it.access == Access.READ }.forEach(::succeed) + + assertEquals(calls.filter { it.access == Access.READ }.map { "$session ${call(it)}" }, received) + + for (read in received.map { it.substringAfter(' ').substringBefore(' ') }) { + assertContains(refusal("client_click", session), "client_$read") + } + } + + @Test + fun `an official session refuses input before it says that it has no client`() { + server.call("session_start", """{"target":"Old School RuneScape","wait_ms":0}""") + + assertEquals(refusal("client_type", "s1"), server.error("client_type", """{"text":"hello"}""")) + assertEquals( + "session s1 is still launching; call session_start with this session to wait for it", + server.error("client_state", "{}"), + ) + } + + @Test + fun `the session list says whether each session can be driven`() { + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + server.call("session_start", """{"target":"Old School RuneScape","wait_ms":0}""") + server.manager.attach(43701, target(1, "My Server")) + + assertEquals( + listOf("s1 launched drive", "s2 launched read", "s3 attached read"), + server.callJson("session_list").get("sessions").map { + "${it.get("session").asText()} ${it.get("kind").asText()} ${it.get("access").asText()}" + }, + ) + } + + @Test + fun `a target that takes its jav_config from the official game is read-only whatever its id`() { + val mirror = target(1, "Mirror").copy(javConfigUrl = "https://oldschool.config.runescape.com/jav_config.ws") + server.launcher.targets = listOf(target(0, "Old School RuneScape"), mirror, target(2, "My Server")) + + val started = server.callJson("session_start", """{"wait_ms":0}""") + val forced = server.callJson("session_start", """{"target":"Mirror","wait_ms":0}""") + + assertEquals("My Server drive", "${started.get("target").asText()} ${started.get("access").asText()}") + assertEquals("Mirror read", "${forced.get("target").asText()} ${forced.get("access").asText()}") + } + + /** The op and the arguments the plugin receives for the call. */ + private fun call(call: Call): String { + val forwarded = + when (call.tool) { + "client_widgets" -> """{"limit":200}""" + "client_entities" -> """{"radius":15,"limit":100}""" + "client_login" -> """{"username":"alice","password":"any","wait_ms":15000}""" + else -> call.arguments + } + + return "${call.op} $forwarded" + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt index 13bddb1b..229ebb71 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt @@ -43,7 +43,7 @@ internal val loginHeader = ) internal class FakeLauncher : ClientLauncher { - private val targets = listOf(target(0, "Old School RuneScape"), target(1, "My Server")) + var targets = listOf(target(0, "Old School RuneScape"), target(1, "My Server")) val reserved = ArrayList() // A client that goes away is killed from the bridge's reader thread. diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt index 03ce5191..b021b77b 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt @@ -1,5 +1,6 @@ package net.rsprox.mcp.session +import net.rsprox.mcp.bridge.Access import net.rsprox.mcp.bridge.TestHub import net.rsprox.mcp.bridge.awaitTrue import net.rsprox.mcp.packets.PacketQuery @@ -158,8 +159,9 @@ class SessionManagerRecoveryTest { assertEquals(listOf("connected", "stopped"), manager.list().map { it.state }) assertEquals("connected", manager.start(null, "s1", 0).state) - val link = manager.resolve("s1").link("client_state") - assertEquals(7, link.call("state", McpDispatcher.MAPPER.createObjectNode(), 10_000).get("tick").asInt()) + val link = manager.resolve("s1").link("client_state", Access.READ) + val state = link.call("state", McpDispatcher.MAPPER.createObjectNode(), Access.READ, 10_000) + assertEquals(7, state.get("tick").asInt()) assertEquals(listOf("CLIENT_LAUNCHED", "CLIENT_EXITED"), manager.markers("s2")) assertEquals("stopped by caller", manager.stop("s1").reason) } finally { From 1bdfcec19e302165d62907efdc0e6686ba22a7e2 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:22:23 +0200 Subject: [PATCH 55/72] feat(mcp-bridge): build the ops that send input only when input is allowed The welcome now carries `access`, and the plugin builds its op table from it once: a connection with read access has only the ops that read, and no mouse exists to send input with. Input is allowed only when rsprox grants it and the client is not the stock one, whose home `.runelite` is what rsprox launches the official game into; a welcome without the field grants nothing. The camera's read is the op `camera` and its turn `camera_turn`, and the login joins the ops that send input. The protocol is now 2. A version 1 jar does not read `access` and would drive on a read welcome, so the hub rejects it and the server installs the new jar in its place. The plugin is listed in RuneLite's plugin list, under a name and description that say it never sends input on the official game, so it can be switched off there. Switching it off closes the connection. Co-Authored-By: Claude Fable 5.1 --- .../kotlin/net/rsprox/mcp/bridge/BridgeHub.kt | 10 ++-- .../net/rsprox/mcp/bridge/BridgeHubTest.kt | 15 ++++-- .../net/rsprox/mcp/bridge/FakePlugin.kt | 2 +- .../mcp/session/SessionManagerRecoveryTest.kt | 4 +- .../java/net/rsprox/mcpbridge/Access.java | 30 +++++++++++ .../rsprox/mcpbridge/BridgeConnection.java | 20 ++++--- .../java/net/rsprox/mcpbridge/Camera.java | 22 ++++---- .../net/rsprox/mcpbridge/McpBridgePlugin.java | 14 ++--- .../main/java/net/rsprox/mcpbridge/Ops.java | 54 ++++++++++++------- 9 files changed, 116 insertions(+), 55 deletions(-) create mode 100644 runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Access.java diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt index 22ad86ca..f165368e 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeHub.kt @@ -192,12 +192,13 @@ public class BridgeHub( logger.info { "Bridge connected for session ${listener.session} (httpPort $httpPort)" } } - /** Build the welcome that tells the plugin its session and the rendering to use. */ + /** Build the welcome that tells the plugin its session, what it may do with the client and the rendering to use. */ private fun welcome(listener: BridgeListener): JsonNode = MAPPER .createObjectNode() .put("welcome", PROTOCOL) .put("session", listener.session) + .put("access", listener.access.wire) .put("softwareRendering", rendering == Rendering.SOFTWARE) /** @@ -246,8 +247,11 @@ public class BridgeHub( /** The JSON mapper of the wire and the rendezvous file. */ private val MAPPER: ObjectMapper = jacksonObjectMapper() - /** The version of the wire protocol that this hub speaks. */ - private const val PROTOCOL = 1 + /** + * The version of the wire protocol that this hub speaks. Version 2 added `access` to the welcome, and + * a plugin that does not read it would drive a client it was meant to read, so a version 1 jar is rejected. + */ + private const val PROTOCOL = 2 /** The number of plugins that may wait to be accepted. */ private const val BACKLOG = 16 diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index c0f05219..aad0c9ba 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -29,7 +29,7 @@ class BridgeHubTest { private fun connect(listener: RecordingListener = RecordingListener()): Pair { hub.expect(43650, listener) val plugin = fixture.dial(43650) - assertEquals(1, plugin.read()?.get("welcome")?.asInt()) + assertEquals(2, plugin.read()?.get("welcome")?.asInt()) return plugin to listener.hello.get(10, TimeUnit.SECONDS).first } @@ -49,8 +49,9 @@ class BridgeHubTest { val plugin = fixture.dial(43650) val welcome = plugin.read()!! - assertEquals(1, welcome.get("welcome").asInt()) + assertEquals(2, welcome.get("welcome").asInt()) assertEquals("s3", welcome.get("session").asText()) + assertEquals("drive", welcome.get("access").asText()) assertFalse(welcome.get("softwareRendering").asBoolean()) assertEquals(4242, listener.hello.get(10, TimeUnit.SECONDS).second) } @@ -65,7 +66,7 @@ class BridgeHubTest { assertNull(intruder.read()) assertFalse(listener.hello.isDone) - assertEquals(1, fixture.dial(43650).read()?.get("welcome")?.asInt()) + assertEquals(2, fixture.dial(43650).read()?.get("welcome")?.asInt()) assertEquals(emptyList(), listener.rejected) } @@ -91,8 +92,12 @@ class BridgeHubTest { } @Test - fun `a link made for reading refuses an op that sends input and forwards the next op that reads`() { - val (plugin, link) = connect(RecordingListener(access = Access.READ)) + fun `a listener that reads is welcomed with read access, and its link refuses an op that sends input`() { + val listener = RecordingListener(access = Access.READ) + hub.expect(43650, listener) + val plugin = fixture.dial(43650) + assertEquals("read", plugin.read()?.get("access")?.asText()) + val link = listener.hello.get(10, TimeUnit.SECONDS).first val refused = assertFailsWith { link.call("click", args("""{"x":1,"y":2}"""), Access.DRIVE, TIMEOUT_MS) } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt index b9be9a8c..aac41ffa 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/FakePlugin.kt @@ -36,7 +36,7 @@ internal class TestHub : AutoCloseable { fun dial( httpPort: Int, token: String? = null, - protocol: Int = 1, + protocol: Int = 2, ): FakePlugin = FakePlugin.dial(rendezvous, httpPort, token, protocol).also { plugins += it } override fun close() { diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt index b021b77b..3969efe9 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionManagerRecoveryTest.kt @@ -75,11 +75,11 @@ class SessionManagerRecoveryTest { val manager = manager() manager.start(null, null, 0) - fixture.dial(43650, protocol = 2).read() + fixture.dial(43650, protocol = 1).read() assertEquals( "the client started, but its bridge plugin was rejected: " + - "bridge protocol 2 is not supported; the installed plugin jar is stale", + "bridge protocol 1 is not supported; the installed plugin jar is stale", manager.awaitStopped("s1").reason, ) } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Access.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Access.java new file mode 100644 index 00000000..87c0abe3 --- /dev/null +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Access.java @@ -0,0 +1,30 @@ +package net.rsprox.mcpbridge; + +import com.google.gson.JsonObject; +import java.io.File; + +/** What rsprox may do with this client: read it, or also drive it by sending it input. */ +enum Access { + /** Read the client. The ops that send input are never built for a connection with this access. */ + READ, + + /** Read the client and send it input. */ + DRIVE; + + /** The name of the home directory of the stock RuneLite client, which rsprox launches the official game with. */ + private static final String STOCK_HOME = ".runelite"; + + /** + * Decide the access from rsprox's welcome and the client's own home directory. Input is allowed + * only when the welcome grants it and this is not the stock client. rsprox launches the official + * game into the stock home and a custom server into {@code .rlcustom}, so the stock home is the + * client's own evidence that it plays the live game, whatever rsprox said. A welcome without the + * field grants nothing. + */ + static Access of(JsonObject welcome, File clientHome) { + boolean granted = welcome.has("access") && "drive".equals(welcome.get("access").getAsString()); + boolean stockClient = STOCK_HOME.equals(clientHome.getName()); + + return granted && !stockClient ? DRIVE : READ; + } +} diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java index bc9622f6..48611666 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/BridgeConnection.java @@ -19,19 +19,22 @@ import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.client.RuneLite; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * The socket to rsprox: one JSON object per line in each direction. A reader thread hands each request - * to a small worker pool, so a request that waits on the game never holds up the next one. + * to a small worker pool, so a request that waits on the game never holds up the next one. The ops a + * connection can run are built once, from the access that rsprox's welcome and the client's own home + * directory allow, so a connection made for reading has no op that sends input. */ final class BridgeConnection implements AutoCloseable { /** The logger of the connection. */ private static final Logger log = LoggerFactory.getLogger(BridgeConnection.class); - /** The version of the wire protocol that this plugin speaks. */ - private static final int PROTOCOL = 1; + /** The version of the wire protocol that this plugin speaks. Version 2 has `access` in the welcome. */ + private static final int PROTOCOL = 2; /** The longest wait for rsprox to accept the socket. */ private static final int CONNECT_TIMEOUT_MS = 2_000; @@ -81,10 +84,11 @@ private BridgeConnection( } /** - * Connect to the rsprox that launched this client on {@code httpPort}. Returns null, having started - * no thread, when there is no rsprox to talk to or it does not expect this client. + * Connect to the rsprox that launched this client on {@code httpPort}, and build the ops for the + * access its welcome allows. Returns null, having started no thread, when there is no rsprox to + * talk to or it does not expect this client. */ - static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops ops) { + static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, GameAccess game) { if (!Files.isRegularFile(rendezvous)) return null; // The protocol spells out an absent player as null, which the client's Gson would drop. @@ -125,9 +129,11 @@ static BridgeConnection dial(Path rendezvous, int httpPort, Gson clientGson, Ops socket.setSoTimeout(0); boolean software = answer.has("softwareRendering") && answer.get("softwareRendering").getAsBoolean(); + Access access = Access.of(answer, RuneLite.RUNELITE_DIR); + Ops ops = access == Access.DRIVE ? Ops.driving(game) : Ops.readOnly(game); BridgeConnection connection = new BridgeConnection(socket, reader, writer, gson, ops, software); connection.start(); - log.info("rsprox MCP bridge connected as session {}", answer.get("session")); + log.info("rsprox MCP bridge connected as session {} with {} access", answer.get("session"), access); return connection; } catch (IOException | RuntimeException e) { diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java index 4d9669ac..a68e7fb1 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Camera.java @@ -8,8 +8,9 @@ import net.runelite.api.coords.WorldPoint; /** - * Reads and turns the camera. A turn waits until the camera has settled, within a bound, because the - * client moves the camera toward its target over several ticks. + * Reads and turns the camera. Reading is the op {@code camera}; the turn, which is input, is the op + * {@code camera_turn}. A turn waits until the camera has settled, within a bound, because the client + * moves the camera toward its target over several ticks. */ final class Camera implements Ops.Op { /** The cycles over which neither the yaw nor the pitch may change for the camera to count as settled. */ @@ -26,24 +27,21 @@ final class Camera implements Ops.Op { this.game = game; } - /** Determine if the arguments ask for a turn of the camera, and not only for a read of it. */ - static boolean turns(JsonObject args) { - return args.has("yaw") || args.has("pitch") || args.has("look_at"); + /** Report the camera's yaw and pitch without touching it. */ + JsonElement read(JsonObject args) throws BridgeException { + return game.onClientThread(client -> describe(client, null)); } /** - * Turn the camera as asked, or read it, and report its yaw and pitch, with whether the looked-at - * target is on screen. + * Turn the camera as asked, wait for it to settle, and report its yaw and pitch, with whether the + * looked-at target is on screen. */ @Override public JsonElement run(JsonObject args) throws BridgeException { String lookAt = Ops.optionalString(args, "look_at"); Target target = lookAt == null ? null : locate(lookAt, args); - - if (turns(args)) { - turn(args, target); - settle(); - } + turn(args, target); + settle(); return game.onClientThread(client -> describe(client, target)); } diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index abd78d8f..99e805ff 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -19,12 +19,14 @@ /** * The in-client half of the rsprox MCP server. The sideload directory is shared with clients that * rsprox did not launch for an agent, so the plugin does nothing unless a running MCP server left a - * rendezvous file and welcomes this client. + * rendezvous file and welcomes this client. It is listed among the client's plugins, and turning it off + * there closes its connection. */ @PluginDescriptor( - name = "RSProx MCP Bridge", - description = "Lets the rsprox MCP server observe and drive this client", - hidden = true + name = "rsprox MCP bridge", + description = "Lets the rsprox MCP server read this client, and drive it on a private server. " + + "On the official game it only reads and never sends input.", + tags = {"rsprox", "mcp"} ) public class McpBridgePlugin extends Plugin { /** The logger of the plugin. */ @@ -75,10 +77,10 @@ protected void startUp() { // The server writes this path in McpMain.kt. Path rendezvous = Paths.get(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"); game = new GameAccess(client, clientThread::invoke, drawManager, eventBus); - Ops ops = new Ops(game); + GameAccess dialed = game; int port = httpPort.getAsInt(); - dial = BridgeDial.start(() -> BridgeConnection.dial(rendezvous, port, gson, ops), this::onConnected); + dial = BridgeDial.start(() -> BridgeConnection.dial(rendezvous, port, gson, dialed), this::onConnected); } /** Stop the GPU plugin when rsprox asked for software rendering. Runs on the thread of the dial. */ diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java index 89f06266..ae521091 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/Ops.java @@ -34,7 +34,11 @@ import net.runelite.api.coords.WorldPoint; import net.runelite.api.widgets.Widget; -/** The operations rsprox can ask for, by name. Coordinates are canvas units everywhere. */ +/** + * The operations rsprox can ask for, by name. Coordinates are canvas units everywhere. The table is + * built once for the access of its connection: {@link #readOnly} holds only ops that read, and + * {@link #driving} adds the ops that send input, together with the one mouse they send it with. + */ final class Ops { /** The shape of a widget reference: a group, a child and an optional dynamic child index. */ private static final Pattern WIDGET_REF = Pattern.compile("(\\d+):(\\d+)(?:\\[(\\d+)])?"); @@ -60,8 +64,8 @@ interface Op { /** The gateway to the client and the threads it must be used on. */ private final GameAccess game; - /** The real mouse of the client. */ - private final Mouse mouse; + /** The camera, which every connection reads and a driving one also turns. */ + private final Camera camera; /** The ops by the name rsprox calls them. */ private final Map table = new HashMap<>(); @@ -69,20 +73,37 @@ interface Op { /** The lock that an op holds for as long as it drives the one mouse and keyboard of the client. */ private final Object input = new Object(); - /** Create the op table for the given game. */ - Ops(GameAccess game) { + /** Create the table of the ops that only read the client. */ + private Ops(GameAccess game) { this.game = game; - this.mouse = new Mouse(game); + this.camera = new Camera(game); reading("state", this::state); reading("widgets", this::widgets); reading("vars", this::vars); reading("screenshot", this::screenshot); - reading("login", this::login); reading("entities", new Entities(game)); - driving("click", this::click); - driving("type", this::type); - driving("interact", new Interact(game, mouse)); - driving("camera", Camera::turns, new Camera(game)); + reading("camera", camera::read); + } + + /** Create the ops of a connection that may only read the client. Nothing in this table sends input. */ + static Ops readOnly(GameAccess game) { + return new Ops(game); + } + + /** + * Create the ops of a connection that may drive the client: the reading ops, plus those that send + * input. The mouse they send it with exists only here. + */ + static Ops driving(GameAccess game) { + Ops ops = new Ops(game); + Mouse mouse = new Mouse(game); + ops.driving("login", ops::login); + ops.driving("click", args -> ops.click(mouse, args)); + ops.driving("type", ops::type); + ops.driving("interact", new Interact(game, mouse)); + ops.driving("camera_turn", ops.camera); + + return ops; } /** Register an op that produces no input, which runs beside any other op. */ @@ -92,12 +113,7 @@ private void reading(String name, Op op) { /** Register an op that produces input, which runs alone among such ops from its start to its end. */ private void driving(String name, Op op) { - driving(name, args -> true, op); - } - - /** Register an op that produces input for the arguments that pass the test, and only reads for the others. */ - private void driving(String name, Predicate drives, Op op) { - table.put(name, args -> drives.test(args) ? alone(op, args) : op.run(args)); + table.put(name, args -> alone(op, args)); } /** @@ -370,8 +386,8 @@ private JsonElement screenshot(JsonObject args) throws BridgeException { return out; } - /** Click the canvas at the given coordinates, or at the centre of the given widget. */ - private JsonElement click(JsonObject args) throws BridgeException { + /** Click the canvas with the mouse at the given coordinates, or at the centre of the given widget. */ + private JsonElement click(Mouse mouse, JsonObject args) throws BridgeException { boolean right = "right".equals(optionalString(args, "button")); Point point = clickPoint(args); mouse.hover(point.x, point.y); From 71436d94db65b94fbafa36e3f19196680a7e7929 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:26:16 +0200 Subject: [PATCH 56/72] feat(mcp): add a switch that launches clients without the bridge plugin `mcp.plugin=false` in proxy.properties, or `--no-plugin` on the standalone command, makes the server install nothing into the clients it launches. Such a session is `unbridged` from the moment its launcher hands over: the hub expects no hello, every client_* tool is refused with the switch named, and its packets are read as before. The proxy tells the sessions when any client is gone, in the standalone server as in the GUI, so an unbridged client that exits stops its session. Co-Authored-By: Claude Fable 5.1 --- .../kotlin/net/rsprox/mcp/McpExtension.kt | 27 +-------- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 45 ++++++++++++-- .../mcp/session/ProxyServiceLauncher.kt | 9 +-- .../kotlin/net/rsprox/mcp/session/Session.kt | 38 +++++++++--- .../net/rsprox/mcp/session/SessionManager.kt | 30 ++++++++-- .../rsprox/mcp/server/UnbridgedSessionTest.kt | 60 +++++++++++++++++++ .../net/rsprox/mcp/session/SessionFixtures.kt | 2 + .../kotlin/net/rsprox/proxy/ProxyService.kt | 5 ++ .../rsprox/proxy/config/ProxyProperties.kt | 2 + .../net/rsprox/proxy/config/ProxyProperty.kt | 1 + .../proxy/config/ProxyPropertiesTest.kt | 12 ++++ 11 files changed, 183 insertions(+), 48 deletions(-) create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt index acae817b..598446ce 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -6,12 +6,8 @@ import net.rsprox.mcp.server.McpHttpServer import net.rsprox.mcp.server.ToolError import net.rsprox.mcp.server.tools import net.rsprox.mcp.session.SessionManager -import net.rsprox.proxy.ClientListener import net.rsprox.proxy.ProxyExtension import net.rsprox.proxy.ProxyService -import net.rsprox.proxy.binary.BinaryHeader -import net.rsprox.proxy.target.ProxyTargetConfig -import net.rsprox.shared.SessionMonitor import java.io.IOException import java.util.concurrent.atomic.AtomicReference @@ -30,11 +26,7 @@ public class McpExtension : ProxyExtension { } serveOrNull(service.getMcpPort()) { opened -> - val manager = sessionManager(service, sideloadDir = null, Rendering.GPU, opened) - - service.addClientListener(AttachingListener(manager)) - - manager + sessionManager(service, sideloadDir = null, Rendering.GPU, service.getMcpPluginEnabled(), opened) } } @@ -44,23 +36,6 @@ public class McpExtension : ProxyExtension { } } -/** Attaches a session to each client the proxy launches, and ends it when the client is gone. */ -private class AttachingListener( - /** The sessions that the clients are attached to. */ - private val sessions: SessionManager, -) : ClientListener { - /** Attach a session to the client, unless the session manager launched it itself. */ - override fun onClientLaunch( - port: Int, - target: ProxyTargetConfig, - ): SessionMonitor? = sessions.attach(port, target) - - /** End the session that is attached to the client, if any. */ - override fun onClientClosed(port: Int) { - sessions.detach(port) - } -} - /** * Bind the endpoint to [port] and then build the sessions it serves, which may add what must be closed * on a failure to the list they are given. Returns null when the port is taken or the sessions cannot be diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index 11de8fe8..e6c16b71 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -2,6 +2,7 @@ package net.rsprox.mcp import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.parameters.options.default +import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.switch import com.github.ajalt.clikt.parameters.types.int @@ -16,8 +17,12 @@ import net.rsprox.mcp.packets.UnfilteredFilterSetStore import net.rsprox.mcp.server.ToolError import net.rsprox.mcp.session.ProxyServiceLauncher import net.rsprox.mcp.session.SessionManager +import net.rsprox.proxy.ClientListener import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.binary.BinaryHeader import net.rsprox.proxy.config.DEFAULT_MCP_PORT +import net.rsprox.proxy.target.ProxyTargetConfig +import net.rsprox.shared.SessionMonitor import java.nio.file.Path import java.util.Locale import java.util.concurrent.atomic.AtomicReference @@ -48,6 +53,12 @@ public class McpCommand : CliktCommand(name = "mcp") { /** The name of the target to launch at startup, or null to launch none. */ private val autostart by option("--start", help = "Target name to launch immediately") + /** Whether launched clients go without the bridge plugin, so that only their packets are read. */ + private val noPlugin by option( + "--no-plugin", + help = "Launch clients without the bridge plugin, so that only their packets can be read", + ).flag() + /** Serve the MCP endpoint, start the proxy and the bridge hub, and run until the process is killed. */ override fun run() { Locale.setDefault(Locale.US) @@ -97,7 +108,7 @@ public class McpCommand : CliktCommand(name = "mcp") { // Leaves the low ports to a GUI that shares the same configured range. repeat(portSkip.coerceAtLeast(1)) { service.allocatePort() } - return sessionManager(service, sideloadDir, rendering, opened) + return sessionManager(service, sideloadDir, rendering, plugin = !noPlugin, opened) } private companion object { @@ -108,15 +119,18 @@ public class McpCommand : CliktCommand(name = "mcp") { /** * Start the bridge hub and build the sessions of a started proxy, with the stores the proxy holds at - * this moment. Adds what must be closed when a later step fails to [opened]. + * this moment. The sessions hear of every client the proxy launches and closes. With [plugin] false, + * launched clients get no bridge plugin, so only their packets can be read. Adds what must be closed + * when a later step fails to [opened]. */ internal fun sessionManager( service: ProxyService, sideloadDir: Path?, rendering: Rendering, + plugin: Boolean, opened: MutableList, ): SessionManager { - val launcher = ProxyServiceLauncher(service, BridgeJar(sideloadDir)) + val launcher = ProxyServiceLauncher(service, if (plugin) BridgeJar(sideloadDir) else null) // The plugin reads the same path in McpBridgePlugin.java. val rendezvous = Path.of(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json") @@ -128,7 +142,30 @@ internal fun sessionManager( // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) - return SessionManager(launcher, service.settingsStore, hub) + val manager = SessionManager(launcher, service.settingsStore, hub) + service.addClientListener(ClientWatch(manager)) + + return manager +} + +/** + * Attaches a session to each client that something other than the manager launches through the proxy, + * and tells the manager when any client is gone. + */ +private class ClientWatch( + /** The sessions that hear of the clients. */ + private val sessions: SessionManager, +) : ClientListener { + /** Attach a session to the client, unless the session manager launched it itself. */ + override fun onClientLaunch( + port: Int, + target: ProxyTargetConfig, + ): SessionMonitor? = sessions.attach(port, target) + + /** End or stop the session of the client, if any. */ + override fun onClientClosed(port: Int) { + sessions.detach(port) + } } /** diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt index 1cfac88f..ce2eed43 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/ProxyServiceLauncher.kt @@ -15,15 +15,15 @@ import java.util.stream.Collectors internal class ProxyServiceLauncher( /** The proxy that launches the clients. */ private val service: ProxyService, - /** The installer of the bridge plugin. */ - private val bridgeJar: BridgeJar, + /** The installer of the bridge plugin, or null to launch clients without it. */ + private val bridgeJar: BridgeJar?, ) : ClientLauncher { /** Get the proxy targets the proxy is configured with. */ override fun targets(): List = service.proxyTargets - /** Install the bridge plugin, pick a free proxy port and bind the HTTP server of the target. */ + /** Install the bridge plugin, if any, pick a free proxy port and bind the HTTP server of the target. */ override fun reserve(target: ProxyTargetConfig): Reservation { - bridgeJar.installFor(target) + bridgeJar?.installFor(target) // The proxy logs and returns when it cannot bind a proxy port, and a GUI may own any port in the range. val (port, proxyTarget) = firstBound(target) @@ -40,6 +40,7 @@ internal class ProxyServiceLauncher( check(hasClientType(port)) { "proxy port $port could not be bound" } }, launcherExited = forks::allExited, + bridged = bridgeJar != null, ) } diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index d064841a..2977da75 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -84,6 +84,12 @@ public sealed interface ClientState { override val launch: Launch, ) : ClientState + /** The client was launched without the bridge plugin, so only its packets are read. */ + public data class Unbridged( + /** The launch of the client. */ + override val launch: Launch, + ) : ClientState + /** The plugin in the client is connected, so the client can be driven. */ public data class Connected( /** The launch that the client belongs to. */ @@ -303,9 +309,17 @@ public class LaunchedSession internal constructor( after !== before } - /** Register that [launch] of the client began, unless the session has a client already. */ - internal fun launched(launch: Launch) { - transition { if (it is ClientState.Stopped) ClientState.Launching(launch) else it } + /** + * Register that [launch] of the client began, unless the session has a client already. A client + * launched with the plugin is launching until its hello; one launched without it is unbridged. + */ + internal fun launched( + launch: Launch, + bridged: Boolean, + ) { + val next = if (bridged) ClientState.Launching(launch) else ClientState.Unbridged(launch) + + transition { if (it is ClientState.Stopped) next else it } } /** @@ -377,6 +391,11 @@ public class LaunchedSession internal constructor( is ClientState.Connected -> state.link is ClientState.Launching -> throw ToolError("session $id is still launching; call session_start with this session to wait for it") + is ClientState.Unbridged -> + throw ToolError( + "$tool is not available for session $id: its client was launched without the bridge plugin, " + + "which mcp.plugin in proxy.properties, or --no-plugin, turns off, so only its packets can be read", + ) is ClientState.Stopped -> throw ToolError("session $id has no connected client: ${state.reason}") } @@ -396,6 +415,7 @@ public class LaunchedSession internal constructor( when (state) { is ClientState.Stopped -> "stopped" is ClientState.Launching -> "launching" + is ClientState.Unbridged -> "unbridged" is ClientState.Connected -> "connected" }, reason = (state as? ClientState.Stopped)?.reason, @@ -411,16 +431,16 @@ public class LaunchedSession internal constructor( /** Append the lifecycle marker of the state to the packet log. */ private fun mark(state: ClientState) { when (state) { - is ClientState.Launching -> - mark( - "CLIENT_LAUNCHED", - "generation=${state.launch.generation} proxyPort=${state.launch.proxyPort} " + - "httpPort=${state.launch.httpPort}", - ) + is ClientState.Launching -> mark("CLIENT_LAUNCHED", describe(state.launch)) + is ClientState.Unbridged -> mark("CLIENT_LAUNCHED", "${describe(state.launch)} plugin=none") is ClientState.Connected -> mark("CLIENT_CONNECTED", "pid=${state.pid}") is ClientState.Stopped -> mark("CLIENT_EXITED", state.reason) } } + + /** Describe the launch for its marker. */ + private fun describe(launch: Launch): String = + "generation=${launch.generation} proxyPort=${launch.proxyPort} httpPort=${launch.httpPort}" } /** diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index 4e645da2..2170f2aa 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -40,6 +40,8 @@ internal class Reservation( * Asked again and again while [launch] blocks, always by the same thread. */ val launcherExited: () -> Boolean, + /** Whether the bridge plugin was installed for the launch, so that the client's hello is to be expected. */ + val bridged: Boolean, ) /** @@ -117,6 +119,7 @@ public class SessionManager internal constructor( // } is ClientState.Launching -> release(state.launch) + is ClientState.Unbridged -> launcher.kill(state.launch.proxyPort) is ClientState.Connected -> { state.link.close() launcher.kill(state.launch.proxyPort) @@ -143,11 +146,24 @@ public class SessionManager internal constructor( return tap(session) } - /** End the session that is attached to the client on [proxyPort], if any. Idempotent. */ + /** + * Register that the client on [proxyPort] is gone: end the session attached to it, or stop the + * launched session whose unbridged client it was, which nothing else tells about the exit. Idempotent. + */ internal fun detach(proxyPort: Int) { val attached = sessions.filterIsInstance().firstOrNull { it.proxyPort == proxyPort } + attached?.end(CLIENT_CLOSED) + + val unbridged = + launchedSessions().firstOrNull { + val state = it.client + state is ClientState.Unbridged && state.launch.proxyPort == proxyPort + } - attached?.end("the client was closed") + if (unbridged != null) { + unbridged.stop(CLIENT_CLOSED) + synchronized(launchLock) { launcher.kill(proxyPort) } + } } /** Get a snapshot of every session. */ @@ -247,13 +263,14 @@ public class SessionManager internal constructor( val launch = Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort) // Registered before the client is forked: its hello can arrive before the launch call returns. - bridge.expect(launch.httpPort, listener(session, launch)) - session.launched(launch) + if (reservation.bridged) bridge.expect(launch.httpPort, listener(session, launch)) + + session.launched(launch, reservation.bridged) val reason = fork(session, reservation) if (reason == null) { - expectHello(session, launch) + if (reservation.bridged) expectHello(session, launch) return session } @@ -420,6 +437,9 @@ public class SessionManager internal constructor( /** The reason of a session whose client never said hello. */ private const val NEVER_CONNECTED = "the client started, but its bridge plugin never connected" + /** The reason of a session whose client the proxy reported gone. */ + private const val CLIENT_CLOSED = "the client was closed" + /** The refusal of every launch that follows one whose launcher never returned. */ private const val HUNG_LAUNCH = "an earlier launch never completed its handshake; restart the rsprox MCP process" diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt new file mode 100644 index 00000000..7468990b --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt @@ -0,0 +1,60 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.loginHeader +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class UnbridgedSessionTest { + private val server = ToolServer() + + @AfterTest + fun cleanUp() { + server.close() + } + + @Test + fun `a client launched without the plugin is unbridged at once, is not expected by the hub and keeps its packets`() { + server.launcher.bridged = false + + val started = server.callJson("session_start", """{"target":"My Server","wait_ms":10000}""") + + assertEquals("unbridged", started.get("state").asText()) + assertEquals( + "no session expects httpPort 43650", + server.hub.dial(FakeLauncher.FIRST_HTTP_PORT).read()?.get("reject")?.asText(), + ) + assertEquals( + "client_state is not available for session s1: its client was launched without the bridge plugin, " + + "which mcp.plugin in proxy.properties, or --no-plugin, turns off, so only its packets can be read", + server.error("client_state", "{}"), + ) + + server.launcher.monitor.forSession(loginHeader).onLogin(loginHeader) + + assertEquals( + listOf( + "1 L0 T0 P CLIENT_LAUNCHED generation=1 proxyPort=43751 httpPort=43650 plugin=none", + "2 L1 T0 P LOGIN revision=235 world=301", + ), + server.rows("{}"), + ) + } + + @Test + fun `an unbridged client that is closed stops its session, and session_stop kills one that still runs`() { + server.launcher.bridged = false + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + + server.manager.detach(FakeLauncher.FIRST_PROXY_PORT) + server.call("session_stop", """{"session":"s2"}""") + + assertEquals( + listOf("stopped: the client was closed", "stopped: stopped by caller"), + server.manager.list().map { "${it.state}: ${it.reason}" }, + ) + assertEquals(listOf(FakeLauncher.FIRST_PROXY_PORT, FakeLauncher.FIRST_PROXY_PORT + 1), server.launcher.killed) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt index 229ebb71..2ec8ea52 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt @@ -50,6 +50,7 @@ internal class FakeLauncher : ClientLauncher { val killed = CopyOnWriteArrayList() var launch: () -> Unit = {} var launcherExited: () -> Boolean = { false } + var bridged = true // The packet tap of the newest launch, which a test drives the way the proxy does. lateinit var monitor: SessionMonitor @@ -68,6 +69,7 @@ internal class FakeLauncher : ClientLauncher { launch() }, { launcherExited() }, + bridged, ) } diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt index f2aca2a1..edc183ec 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt @@ -35,6 +35,7 @@ import net.rsprox.proxy.config.ProxyProperty.Companion.BIND_TIMEOUT_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.FILTERS_STATUS import net.rsprox.proxy.config.ProxyProperty.Companion.JAV_CONFIG_ENDPOINT import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_ENABLED +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PLUGIN import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PORT import net.rsprox.proxy.config.ProxyProperty.Companion.PROXY_PORT_MIN import net.rsprox.proxy.config.ProxyProperty.Companion.RUNELITE_RSPROX_CONNECTION @@ -514,6 +515,10 @@ public class ProxyService( return properties.getProperty(MCP_PORT) } + public fun getMcpPluginEnabled(): Boolean { + return properties.getProperty(MCP_PLUGIN) + } + public fun setSelectedClient(index: Int) { properties.setProperty(SELECTED_CLIENT, index) properties.saveProperties(PROPERTIES_FILE) diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt index d295fbf9..9eaad1bc 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt @@ -7,6 +7,7 @@ import net.rsprox.proxy.config.ProxyProperty.Companion.BINARY_WRITE_INTERVAL_SEC import net.rsprox.proxy.config.ProxyProperty.Companion.BIND_TIMEOUT_SECONDS import net.rsprox.proxy.config.ProxyProperty.Companion.JAV_CONFIG_ENDPOINT import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_ENABLED +import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PLUGIN import net.rsprox.proxy.config.ProxyProperty.Companion.MCP_PORT import net.rsprox.proxy.config.ProxyProperty.Companion.PROXY_PORT_MIN import net.rsprox.proxy.config.ProxyProperty.Companion.RUNELITE_RSPROX_CONNECTION @@ -98,6 +99,7 @@ public value class ProxyProperties private constructor( // mcp properties.setValue(MCP_ENABLED, true) properties.setValue(MCP_PORT, DEFAULT_MCP_PORT) + properties.setValue(MCP_PLUGIN, true) // other properties.setValue(RUNELITE_RSPROX_CONNECTION, false) return properties diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt index 4b2e6c7f..e592c896 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperty.kt @@ -28,6 +28,7 @@ public class ProxyProperty( // mcp val MCP_ENABLED = ProxyProperty("mcp.enabled", BooleanProperty) val MCP_PORT = ProxyProperty("mcp.port", IntProperty) + val MCP_PLUGIN = ProxyProperty("mcp.plugin", BooleanProperty) // other val RUNELITE_RSPROX_CONNECTION = ProxyProperty("runelite.rsprox.connection", BooleanProperty) diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt index 2c0327c3..133438df 100644 --- a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt +++ b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt @@ -26,4 +26,16 @@ class ProxyPropertiesTest { assertEquals(false, properties.getProperty(ProxyProperty.MCP_ENABLED)) assertEquals(43999, properties.getProperty(ProxyProperty.MCP_PORT)) } + + @Test + fun `the bridge plugin is installed unless the file turns it off`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "app.theme=RuneLite\n") + + assertEquals(true, ProxyProperties(file).getProperty(ProxyProperty.MCP_PLUGIN)) + + Files.writeString(file, "mcp.plugin=false\n") + + assertEquals(false, ProxyProperties(file).getProperty(ProxyProperty.MCP_PLUGIN)) + } } From a31b4da312416fff7a76f537c0060a628882ddff Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:28:03 +0200 Subject: [PATCH 57/72] refactor(mcp): wrap the lines that the access field pushed past 120 columns Co-Authored-By: Claude Fable 5.1 --- mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt | 6 +++++- mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt | 5 +++-- .../kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt | 8 ++++++-- .../net/rsprox/mcp/server/AttachedSessionTest.kt | 11 ++++++----- .../net/rsprox/mcp/server/UnbridgedSessionTest.kt | 2 +- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 7cb59cdb..3d98c1e2 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -394,7 +394,11 @@ private fun clientTool( /** The camera request the arguments call for: a turn, which sends input, when any turn argument is given. */ private fun cameraRequest(args: ObjectNode): Request = - if (CAMERA_TURN_ARGUMENTS.any(args::hasNonNull)) Request("camera_turn", Access.DRIVE) else Request("camera", Access.READ) + if (CAMERA_TURN_ARGUMENTS.any(args::hasNonNull)) { + Request("camera_turn", Access.DRIVE) + } else { + Request("camera", Access.READ) + } /** The arguments of client_camera that ask for a turn. */ private val CAMERA_TURN_ARGUMENTS = listOf("yaw", "pitch", "look_at") diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt index 2977da75..e4538b32 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/Session.kt @@ -204,7 +204,7 @@ public data class SessionSnapshot( val kind: String, /** The name of the proxy target the session belongs to. */ val target: String, - /** `drive` when the client tools that send input may be used on the session, `read` when only those that read may. */ + /** `drive` when the client tools that send input may be used on the session, `read` when only those that read. */ val access: String, /** The state: `stopped`, `launching` or `connected` when launched, `attached` or `ended` when attached. */ val state: String, @@ -394,7 +394,8 @@ public class LaunchedSession internal constructor( is ClientState.Unbridged -> throw ToolError( "$tool is not available for session $id: its client was launched without the bridge plugin, " + - "which mcp.plugin in proxy.properties, or --no-plugin, turns off, so only its packets can be read", + "which mcp.plugin in proxy.properties, or --no-plugin, turns off, so only its packets " + + "can be read", ) is ClientState.Stopped -> throw ToolError("session $id has no connected client: ${state.reason}") diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt index aad0c9ba..0460e578 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/bridge/BridgeHubTest.kt @@ -115,7 +115,10 @@ class BridgeHubTest { val (plugin, link) = connect() plugin.serve { _, _ -> """"err":{"code":"not_found","message":"widget 558:7 is not visible"}""" } - val error = assertFailsWith { link.call("click", args("""{"widget":"558:7"}"""), Access.DRIVE, TIMEOUT_MS) } + val error = + assertFailsWith { + link.call("click", args("""{"widget":"558:7"}"""), Access.DRIVE, TIMEOUT_MS) + } assertEquals("not_found", error.code) assertEquals("widget 558:7 is not visible", error.message) } @@ -131,7 +134,8 @@ class BridgeHubTest { assertEquals("closed", bridgeError(pending).code) assertSame(link, listener.closed.get(10, TimeUnit.SECONDS)) - assertEquals("closed", assertFailsWith { link.call("state", args(), Access.READ, TIMEOUT_MS) }.code) + val refused = assertFailsWith { link.call("state", args(), Access.READ, TIMEOUT_MS) } + assertEquals("closed", refused.code) } private companion object { diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt index 8f9b229b..cf1b1396 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -60,8 +60,8 @@ class AttachedSessionTest { assertEquals( listOf( - """{"session":"s1","kind":"launched","target":"My Server","access":"drive","state":"launching","generation":1,""" + - """"proxyPort":43751,"httpPort":43650,"cursor":1}""", + """{"session":"s1","kind":"launched","target":"My Server","access":"drive","state":"launching",""" + + """"generation":1,"proxyPort":43751,"httpPort":43650,"cursor":1}""", """{"session":"s2","kind":"attached","target":"Local","access":"read","state":"attached",""" + """"proxyPort":43701,"cursor":1}""", ), @@ -81,7 +81,8 @@ class AttachedSessionTest { @Test fun `the session list reports each fact of a login from the moment the proxy knows it`() { val client = attach() - val attached = """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached","proxyPort":43701""" + val attached = + """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached","proxyPort":43701""" val accepted = """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + """"connectedAt":"2023-11-14T22:13:20Z"""" @@ -124,8 +125,8 @@ class AttachedSessionTest { assertEquals( listOf( - """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached","proxyPort":43701,""" + - """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + + """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached",""" + + """"proxyPort":43701,"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + """"connectedAt":"2023-11-14T22:13:20Z","online":true,"transcribing":false},"cursor":2}""", ), sessions(), diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt index 7468990b..c6359777 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/UnbridgedSessionTest.kt @@ -15,7 +15,7 @@ class UnbridgedSessionTest { } @Test - fun `a client launched without the plugin is unbridged at once, is not expected by the hub and keeps its packets`() { + fun `a client launched without the plugin is unbridged at once, unexpected by the hub, and keeps its packets`() { server.launcher.bridged = false val started = server.callJson("session_start", """{"target":"My Server","wait_ms":10000}""") From 27b72cb011380388f1f4d1fe3e0538a5d9ec69d7 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:28:04 +0200 Subject: [PATCH 58/72] docs(mcp): say that the server never sends input to the official game A section at the top names what the tool reads on the official game, what it refuses there, the two layers that hold when the server is wrong, and the three ways to disable the plugin. The new option, the new property and the stale-jar replacement are listed where the others are. The attached-session bullets and two paragraphs are shorter to make room. Co-Authored-By: Claude Fable 5.1 --- mcp/README.md | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index ff3ed944..865cd062 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -9,6 +9,23 @@ GUI, where it attaches to the clients that you launch by hand. The proxy only observes, so it cannot inject or change packets. All input goes through the client as mouse and key events, from a small RuneLite plugin that the server installs before each launch. +## Never input on the official game + +The tool is for testing a private server that you run. On the official game, which is target 0 and +any target whose jav_config comes from a Jagex domain, it only reads: `client_state`, +`client_screenshot`, `client_widgets`, `client_vars`, `client_entities`, `client_camera` without a +turn, and `packets_read`. `client_login`, `client_click`, `client_type`, `client_interact` and a +`client_camera` turn are refused there before anything reaches the client, and `session_list` shows +such a session with `"access":"read"`. Two more layers hold if the server is wrong: the link it makes +for a read-only session forwards no input op, and the plugin builds its input ops only when the +server's welcome grants input and the client is not the stock RuneLite one, whose home directory +`.runelite` is what the official game is launched with. A welcome without the grant is read-only. + +To go further, disable the plugin. Switch "rsprox MCP bridge" off in RuneLite's plugin list, which +closes its connection. Or set `mcp.plugin=false` in `~/.rsprox/proxy.properties` for the GUI, or pass +`--no-plugin` to the standalone server: launched clients then get no plugin, their sessions are +`"state":"unbridged"`, and only `packets_read` works on them. + ## Run it ``` @@ -33,10 +50,12 @@ Options go in `--args`: | `--sideload-dir ` | Where to install the client plugin, when the client does not use the default directory. | | `--port-skip ` | Proxy ports to leave free at the start of the range for a GUI. Default 50. | | `--software-rendering` | Stop the GPU plugin in launched clients. Needed on a virtual display. | +| `--no-plugin` | Launch clients without the plugin. Only their packets can then be read. | The plugin is installed as `rsprox-mcp-bridge.jar` in `~/.rlcustom/sideloaded-plugins` for a custom target and in `~/.runelite/sideloaded-plugins` for the official one. A client that the MCP server did -not launch loads the plugin too, and the plugin then does nothing. +not launch loads the plugin too, and the plugin then does nothing. An installed jar from before a +change to the wire protocol is rejected at its hello and replaced at the next launch. Run one MCP endpoint per user account. The plugin finds its server through the file `~/.rsprox/mcp/bridge.json`, which every endpoint of the account writes, so another `--port` is not @@ -48,14 +67,15 @@ enough. To run this server next to a GUI, turn the GUI's endpoint off first. ./gradlew proxy ``` -The GUI serves the endpoint on `http://127.0.0.1:43580/mcp` from the moment the proxy has started. Two -lines in `~/.rsprox/proxy.properties` change that. Edit the file while the GUI is closed, because the -GUI writes the file again when it saves its own settings. +The GUI serves the endpoint on `http://127.0.0.1:43580/mcp` from the moment the proxy has started. +Three lines in `~/.rsprox/proxy.properties` change that. Edit the file while the GUI is closed, because +the GUI writes the file again when it saves its own settings. | Property | Meaning | |---|---| | `mcp.enabled` | Whether the GUI serves the endpoint. Default `true`. Set it to `false` to turn the endpoint off. | | `mcp.port` | Loopback port of the endpoint. Default 43580. | +| `mcp.plugin` | Whether clients that `session_start` launches get the plugin. Default `true`. | When the port is taken, by a second rsprox or by the standalone server, the GUI logs one line that says so and runs without the endpoint. @@ -66,14 +86,10 @@ picks the client you mean by the `target`, the `proxyPort` and, once you are log under `login`, such as the `world` and the display `name`. The proxy never sees a login name or a password, so neither is listed. RuneScape 3 clients are not attached. -An attached session is read-only: - -- `packets_read` works on it as on any session. -- `session_stop`, `session_start` and every `client_*` tool refuse it with `not available for an - attached session`. The client is yours, so the server never stops it, clicks in it or reads its - screen. -- When you close the session's tab in the GUI, or the client exits, the session becomes - `"state":"ended"`. It stays listed and its packets stay readable. +An attached session is read-only, and its client is yours: `packets_read` works on it, while +`session_stop`, `session_start` and every `client_*` tool refuse it with `not available for an +attached session`. When you close the session's tab in the GUI, or the client exits, the session +becomes `"state":"ended"`. It stays listed and its packets stay readable. Inside the GUI the packet log holds the GUI's view, for attached sessions and for sessions that `session_start` launches alike. The GUI's filters and settings decide which packets are transcribed @@ -93,8 +109,7 @@ xvfb-run -s "-screen 0 1280x800x24" ./gradlew :mcp:run --args="--software-render ``` Without `--software-rendering` everything works except `client_screenshot`, which returns a black -image. Besides a JDK, the machine needs Xvfb and the X11 and font libraries that Java's AWT loads. On -Debian or Ubuntu: +image. Besides a JDK, the machine needs Xvfb and the X11 and font libraries that Java's AWT loads: ``` apt-get install xvfb libxrender1 libxtst6 libxi6 libxext6 libfontconfig1 libfreetype6 fonts-dejavu-core @@ -109,8 +124,7 @@ claude mcp add --transport http rsprox http://127.0.0.1:43580/mcp ``` Any other client takes the same URL in its configuration for an HTTP server. Start the server before -the client connects. The server keeps its sessions and packets for as long as it runs, whatever the -clients do. +the client connects. The server keeps its sessions and packets for as long as it runs. ## Tools From 20f241d022d6700f99408733590f8462a26e1fdb Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:29:44 +0200 Subject: [PATCH 59/72] refactor(mcp-bridge): hand the dial a local of the game access The lambda that dials captured the field, which shutDown nulls. A local of the same name is what the dial gets, and the field is set from it. Co-Authored-By: Claude Fable 5.1 --- .../src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java index 99e805ff..c5c720c6 100644 --- a/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java +++ b/runelite/rsprox-mcp-bridge/src/main/java/net/rsprox/mcpbridge/McpBridgePlugin.java @@ -76,11 +76,11 @@ protected void startUp() { // The server writes this path in McpMain.kt. Path rendezvous = Paths.get(System.getProperty("user.home"), ".rsprox", "mcp", "bridge.json"); - game = new GameAccess(client, clientThread::invoke, drawManager, eventBus); - GameAccess dialed = game; + GameAccess game = new GameAccess(client, clientThread::invoke, drawManager, eventBus); + this.game = game; int port = httpPort.getAsInt(); - dial = BridgeDial.start(() -> BridgeConnection.dial(rendezvous, port, gson, dialed), this::onConnected); + dial = BridgeDial.start(() -> BridgeConnection.dial(rendezvous, port, gson, game), this::onConnected); } /** Stop the GPU plugin when rsprox asked for software rendering. Runs on the thread of the dial. */ From 1b494b4f83f404cfecc957162206029b3080baff Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:29:44 +0200 Subject: [PATCH 60/72] test(mcp): wrap the two attached-session lines over 120 columns Co-Authored-By: Claude Fable 5.1 --- .../kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt index cf1b1396..d66422ef 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/AttachedSessionTest.kt @@ -82,7 +82,8 @@ class AttachedSessionTest { fun `the session list reports each fact of a login from the moment the proxy knows it`() { val client = attach() val attached = - """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached","proxyPort":43701""" + """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached",""" + + """"proxyPort":43701""" val accepted = """"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + """"connectedAt":"2023-11-14T22:13:20Z"""" @@ -126,8 +127,9 @@ class AttachedSessionTest { assertEquals( listOf( """{"session":"s1","kind":"attached","target":"Local","access":"read","state":"attached",""" + - """"proxyPort":43701,"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3","localPlayerIndex":7,""" + - """"connectedAt":"2023-11-14T22:13:20Z","online":true,"transcribing":false},"cursor":2}""", + """"proxyPort":43701,"login":{"epoch":1,"revision":235,"world":301,"host":"127.0.1.3",""" + + """"localPlayerIndex":7,"connectedAt":"2023-11-14T22:13:20Z","online":true,""" + + """"transcribing":false},"cursor":2}""", ), sessions(), ) From 95ab0f9d686f2501dcac2745cd7f80f0629a8d5d Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:34:38 +0200 Subject: [PATCH 61/72] feat(proxy): leave the MCP endpoint of the GUI off unless it is turned on The endpoint has no password, so the GUI serves it only to a user who asked for it. mcp.enabled now defaults to false, for a new properties file and for an existing one without the key. A file that sets the key keeps its value. Co-Authored-By: Claude Opus 5.5 --- .../rsprox/proxy/config/ProxyProperties.kt | 2 +- .../proxy/config/ProxyPropertiesTest.kt | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt index 9eaad1bc..554d13ed 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/config/ProxyProperties.kt @@ -97,7 +97,7 @@ public value class ProxyProperties private constructor( properties.setValue(APP_WIDTH, 800) properties.setValue(APP_HEIGHT, 600) // mcp - properties.setValue(MCP_ENABLED, true) + properties.setValue(MCP_ENABLED, false) properties.setValue(MCP_PORT, DEFAULT_MCP_PORT) properties.setValue(MCP_PLUGIN, true) // other diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt index 133438df..79f2d368 100644 --- a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt +++ b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt @@ -2,28 +2,37 @@ package net.rsprox.proxy.config import java.nio.file.Files import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals class ProxyPropertiesTest { @Test - fun `a properties file from before the MCP endpoint reads it as on, at the default port`() { + fun `a properties file from before the MCP endpoint reads it as off, at the default port`() { val file = Files.createTempFile("proxy", ".properties") Files.writeString(file, "app.theme=RuneLite\n") val properties = ProxyProperties(file) - assertEquals(true, properties.getProperty(ProxyProperty.MCP_ENABLED)) + assertEquals(false, properties.getProperty(ProxyProperty.MCP_ENABLED)) assertEquals(43580, properties.getProperty(ProxyProperty.MCP_PORT)) } @Test - fun `the MCP endpoint can be turned off and moved to another port`() { + fun `a new properties file is written with the MCP endpoint off`() { + val file = Files.createTempDirectory("proxy").resolve("proxy.properties") + + assertEquals(false, ProxyProperties(file).getProperty(ProxyProperty.MCP_ENABLED)) + assertContains(Files.readAllLines(file), "mcp.enabled=false") + } + + @Test + fun `a properties file that turns the MCP endpoint on and moves it to another port is kept`() { val file = Files.createTempFile("proxy", ".properties") - Files.writeString(file, "mcp.enabled=false\nmcp.port=43999\n") + Files.writeString(file, "mcp.enabled=true\nmcp.port=43999\n") val properties = ProxyProperties(file) - assertEquals(false, properties.getProperty(ProxyProperty.MCP_ENABLED)) + assertEquals(true, properties.getProperty(ProxyProperty.MCP_ENABLED)) assertEquals(43999, properties.getProperty(ProxyProperty.MCP_PORT)) } From f63d18e516efc8d0f72b3fe192f573f650544768 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:35:42 +0200 Subject: [PATCH 62/72] test(mcp): cover that an endpoint that is off serves nothing The check of mcp.enabled moves out of the extension into a function that takes the flag, since a test cannot build a proxy service without the properties file of the user. The log line of an endpoint that is off names the menu item and the property that turn it on. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/McpExtension.kt | 32 ++++++++++++------- .../kotlin/net/rsprox/mcp/McpExtensionTest.kt | 28 ++++++++++++++++ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt index 598446ce..d5be1e18 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -17,23 +17,33 @@ import java.util.concurrent.atomic.AtomicReference * setting stores of the GUI, so the packet logs hold what the GUI shows. */ public class McpExtension : ProxyExtension { - /** Serve the endpoint on the configured port, unless it is turned off or the port is taken. */ + /** Serve the endpoint on the configured port, if the user turned it on and the port is free. */ override fun start(service: ProxyService) { - if (!service.getMcpEnabled()) { - logger.info { "The MCP endpoint is turned off by mcp.enabled in proxy.properties" } - - return - } - - serveOrNull(service.getMcpPort()) { opened -> + serveIfEnabled(service.getMcpEnabled(), service.getMcpPort()) { opened -> sessionManager(service, sideloadDir = null, Rendering.GPU, service.getMcpPluginEnabled(), opened) } } +} - private companion object { - /** The logger of the extension. */ - private val logger = InlineLogger() +/** + * Serve the endpoint on [port] as [serveOrNull] does when [enabled] says the user turned it on. Otherwise + * bind nothing, build no sessions and return null, since the endpoint has no password. + */ +internal fun serveIfEnabled( + enabled: Boolean, + port: Int, + sessions: (opened: MutableList) -> SessionManager, +): McpHttpServer? { + if (!enabled) { + endpointLogger.info { + "The MCP endpoint is off. Turn it on with File > Serve MCP Endpoint, or with mcp.enabled in " + + "proxy.properties, and restart rsprox." + } + + return null } + + return serveOrNull(port, sessions) } /** diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt index b1e583d8..637a0e93 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt @@ -7,6 +7,7 @@ import java.net.ServerSocket import java.util.ServiceLoader import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull class McpExtensionTest { @@ -19,6 +20,33 @@ class McpExtensionTest { assertEquals(listOf>(McpExtension::class.java), found) } + @Test + fun `an endpoint that is turned off is not served and builds no sessions`() { + var built = false + + val served = + serveIfEnabled(enabled = false, freePort()) { + built = true + error("no session is built for an endpoint that is off") + } + + assertNull(served) + assertFalse(built) + } + + @Test + fun `an endpoint that is turned on serves the tools on its port`() { + var port = 0 + + val served = + ToolServer { sessions -> + port = freePort() + checkNotNull(serveIfEnabled(enabled = true, port) { sessions }) + } + + served.use { server -> assertEquals(port, server.port) } + } + @Test fun `a port that is taken leaves no endpoint, builds no sessions and throws nothing`() { val holder = McpHttpServer(0, emptyList(), "1.2.3").also { it.start() } From ab48e9e446d787a86fb7edbcb84e554bbbf68a2d Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:36:49 +0200 Subject: [PATCH 63/72] feat(proxy): add a setter that saves whether the MCP endpoint is on It writes mcp.enabled to proxy.properties the way the other setters of the GUI's settings do. Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/net/rsprox/proxy/ProxyService.kt | 5 +++++ .../net/rsprox/proxy/config/ProxyPropertiesTest.kt | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt index edc183ec..b64df8bd 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyService.kt @@ -507,6 +507,11 @@ public class ProxyService( return properties.getPropertyOrNull(FILTERS_STATUS) ?: 0 } + public fun setMcpEnabled(enabled: Boolean) { + properties.setProperty(MCP_ENABLED, enabled) + properties.saveProperties(PROPERTIES_FILE) + } + public fun getMcpEnabled(): Boolean { return properties.getProperty(MCP_ENABLED) } diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt index 79f2d368..88311a77 100644 --- a/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt +++ b/proxy/src/test/kotlin/net/rsprox/proxy/config/ProxyPropertiesTest.kt @@ -36,6 +36,18 @@ class ProxyPropertiesTest { assertEquals(43999, properties.getProperty(ProxyProperty.MCP_PORT)) } + @Test + fun `an MCP endpoint that was turned on and saved is on when the file is read again`() { + val file = Files.createTempFile("proxy", ".properties") + Files.writeString(file, "app.theme=RuneLite\n") + val properties = ProxyProperties(file) + + properties.setProperty(ProxyProperty.MCP_ENABLED, true) + properties.saveProperties(file) + + assertEquals(true, ProxyProperties(file).getProperty(ProxyProperty.MCP_ENABLED)) + } + @Test fun `the bridge plugin is installed unless the file turns it off`() { val file = Files.createTempFile("proxy", ".properties") From 162780dd9575309412abb5ef3c34d5f57219d73b Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:36:49 +0200 Subject: [PATCH 64/72] feat(gui): add a File menu item that turns the MCP endpoint on and off "Serve MCP Endpoint" is a checkbox item between the log folders and Exit. It saves mcp.enabled and takes effect at the next start, which a dialog says when the choice differs from what this run started with. The dialog and the tooltip say that the endpoint has no password. The endpoint is not stopped while the GUI runs. The proxy cannot take a monitor off a running client, the bridge hub's shutdown hook is not kept, and closing the hub's links kills the clients that the endpoint launched. Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/net/rsprox/gui/App.kt | 23 ++++++++++++++ .../net/rsprox/gui/McpEndpointNotice.kt | 23 ++++++++++++++ .../net/rsprox/gui/McpEndpointNoticeTest.kt | 31 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt create mode 100644 gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt index 60327950..96a39e5d 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt @@ -24,6 +24,7 @@ import java.awt.event.WindowEvent import java.net.URI import java.util.prefs.Preferences import javax.swing.BorderFactory +import javax.swing.JCheckBoxMenuItem import javax.swing.JFrame import javax.swing.JMenu import javax.swing.JMenuItem @@ -44,6 +45,7 @@ public class App { public val statusBar: StatusBar = StatusBar(transcriptionManager) private lateinit var sideBar: SideBar private lateinit var homePanel: HomePanel + private val mcpEnabledAtStart: Boolean = service.getMcpEnabled() public fun init() { installFileChooserStateStore() @@ -169,6 +171,16 @@ public class App { addSeparator() + val serveMcpEndpoint = JCheckBoxMenuItem("Serve MCP Endpoint", mcpEnabledAtStart) + serveMcpEndpoint.mnemonic = 'M'.code + serveMcpEndpoint.toolTipText = MCP_ENDPOINT_TOOLTIP + serveMcpEndpoint.addActionListener { + this@App.setMcpEnabled(serveMcpEndpoint.isSelected) + } + add(serveMcpEndpoint) + + addSeparator() + val exitItem = JMenuItem("Exit") exitItem.mnemonic = 'X'.code exitItem.accelerator = KeyStroke.getKeyStroke("alt F4") @@ -283,6 +295,17 @@ public class App { homePanel.refreshRecentDumps() } + private fun setMcpEnabled(enabled: Boolean) { + service.setMcpEnabled(enabled) + val notice = mcpRestartNotice(mcpEnabledAtStart, enabled, service.getMcpPort()) ?: return + JOptionPane.showMessageDialog( + frame, + notice, + "MCP Endpoint", + JOptionPane.INFORMATION_MESSAGE, + ) + } + private fun openBinaryLogsFolder() { openFolder(BINARY_PATH, "You have not created any logs yet.") } diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt new file mode 100644 index 00000000..7d15e163 --- /dev/null +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt @@ -0,0 +1,23 @@ +package net.rsprox.gui + +internal const val MCP_ENDPOINT_TOOLTIP: String = + "No password: any program on this computer can read every session's packets, chat included. " + + "Applies after a restart." + +/** + * The endpoint starts and stops only with RSProx, so a switch away from what this run started with + * takes effect at the next start. Returns what to tell the user then, and null when nothing is pending. + */ +internal fun mcpRestartNotice( + enabledAtStart: Boolean, + enabled: Boolean, + port: Int, +): String? = + when { + enabled == enabledAtStart -> null + enabled -> + "RSProx serves the MCP endpoint on http://127.0.0.1:$port/mcp after a restart.\n\n" + + "The endpoint has no password. Any program on this computer can read the packets of every " + + "session through it, chat included." + else -> "RSProx keeps serving the MCP endpoint until it is restarted." + } diff --git a/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt new file mode 100644 index 00000000..8e33849f --- /dev/null +++ b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt @@ -0,0 +1,31 @@ +package net.rsprox.gui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class McpEndpointNoticeTest { + @Test + fun `turning the endpoint on says where it is served after a restart and what it exposes`() { + assertEquals( + "RSProx serves the MCP endpoint on http://127.0.0.1:43999/mcp after a restart.\n\n" + + "The endpoint has no password. Any program on this computer can read the packets of every " + + "session through it, chat included.", + mcpRestartNotice(enabledAtStart = false, enabled = true, port = 43999), + ) + } + + @Test + fun `turning the endpoint off says that it is served until a restart`() { + assertEquals( + "RSProx keeps serving the MCP endpoint until it is restarted.", + mcpRestartNotice(enabledAtStart = true, enabled = false, port = 43999), + ) + } + + @Test + fun `switching back to what this run started with needs no notice`() { + assertNull(mcpRestartNotice(enabledAtStart = true, enabled = true, port = 43999)) + assertNull(mcpRestartNotice(enabledAtStart = false, enabled = false, port = 43999)) + } +} From 4eebe808e38ace677c82d3d4f3149c6fa6dec968 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:37:20 +0200 Subject: [PATCH 65/72] docs(mcp): say that the GUI serves the endpoint only once it is turned on The GUI section names the menu item, the property behind it and the restart that either switch needs. The introduction, the note on running next to a GUI and the warning no longer read as if the endpoint were on from the first start. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 865cd062..c81ad8f0 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -3,8 +3,8 @@ This module runs rsprox without its GUI and serves it to a coding agent as an [MCP](https://modelcontextprotocol.io) server. The agent launches a RuneLite client through the proxy, reads the decoded packets in both directions, and looks at and drives the client. It is meant for -checking a private server end to end with a real client. The same endpoint also runs inside the rsprox -GUI, where it attaches to the clients that you launch by hand. +checking a private server end to end with a real client. The rsprox GUI serves the same endpoint once +you turn it on, and attaches it to the clients that you launch by hand. The proxy only observes, so it cannot inject or change packets. All input goes through the client as mouse and key events, from a small RuneLite plugin that the server installs before each launch. @@ -59,7 +59,7 @@ change to the wire protocol is rejected at its hello and replaced at the next la Run one MCP endpoint per user account. The plugin finds its server through the file `~/.rsprox/mcp/bridge.json`, which every endpoint of the account writes, so another `--port` is not -enough. To run this server next to a GUI, turn the GUI's endpoint off first. +enough. To run this server next to a GUI, leave the GUI's endpoint off. ## Run it inside the GUI @@ -67,14 +67,18 @@ enough. To run this server next to a GUI, turn the GUI's endpoint off first. ./gradlew proxy ``` -The GUI serves the endpoint on `http://127.0.0.1:43580/mcp` from the moment the proxy has started. -Three lines in `~/.rsprox/proxy.properties` change that. Edit the file while the GUI is closed, because -the GUI writes the file again when it saves its own settings. +The GUI serves the endpoint only after you turn it on. Tick File > Serve MCP Endpoint and restart the +GUI. From then on it serves `http://127.0.0.1:43580/mcp` at every start, from the moment the proxy has +started. Unticking the item also takes effect at the next start, so the GUI keeps serving until it is +closed. + +The item saves `mcp.enabled` in `~/.rsprox/proxy.properties`. Edit that file only while the GUI is +closed, because the GUI writes it again when it saves its own settings. | Property | Meaning | |---|---| -| `mcp.enabled` | Whether the GUI serves the endpoint. Default `true`. Set it to `false` to turn the endpoint off. | -| `mcp.port` | Loopback port of the endpoint. Default 43580. | +| `mcp.enabled` | Whether the GUI serves the endpoint. Default `false`. | +| `mcp.port` | Loopback port of the endpoint. Default 43580. The GUI has no control for it. | | `mcp.plugin` | Whether clients that `session_start` launches get the plugin. Default `true`. | When the port is taken, by a second rsprox or by the standalone server, the GUI logs one line that @@ -98,7 +102,7 @@ see its packets. The standalone server has no such filters and logs every packet The endpoint has no password. Any program that runs under your user account, and any other user of the machine, can reach it on loopback and read everything the log holds, including public and -private chat, for as long as the GUI runs. Turn `mcp.enabled` off when that is not acceptable. +private chat, for as long as the GUI runs. That is why it is off until you turn it on. ## Run it without a display From d7cdb37e810cadd973a5ff89d68eaba1c633e7be Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:47:20 +0200 Subject: [PATCH 66/72] feat(mcp): let a gate decide whether a request may still act on a client A request can run for minutes, and its thread cannot be stopped from outside. The session manager now forks a client, kills one for session_stop and sends one a request only through a gate, which the caller of the manager may close. Closing waits for the acts that are passing, which are short, and refuses every later one. A launch that the proxy returns from after the gate closed counts as failed, so its client is killed. The standalone server builds its sessions with the default gate, which nothing closes. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/bridge/BridgeLink.kt | 21 ++- .../main/kotlin/net/rsprox/mcp/bridge/Gate.kt | 45 ++++++ .../kotlin/net/rsprox/mcp/server/Tools.kt | 5 +- .../net/rsprox/mcp/session/SessionManager.kt | 59 +++++--- .../kotlin/net/rsprox/mcp/McpExtensionTest.kt | 4 +- .../net/rsprox/mcp/server/ClosedGateTest.kt | 138 ++++++++++++++++++ .../net/rsprox/mcp/server/ToolServer.kt | 19 ++- .../net/rsprox/mcp/session/SessionFixtures.kt | 4 + 8 files changed, 256 insertions(+), 39 deletions(-) create mode 100644 mcp/src/main/kotlin/net/rsprox/mcp/bridge/Gate.kt create mode 100644 mcp/src/test/kotlin/net/rsprox/mcp/server/ClosedGateTest.kt diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt index 623c48a2..f5c949a0 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/BridgeLink.kt @@ -63,18 +63,20 @@ public class BridgeLink internal constructor( } /** - * Send one request, which needs [access], and block the calling thread for its reply. + * Send one request, which needs [access], and block the calling thread for its reply. The request + * is sent only through the open [gate]. * * @return the `ok` value of the reply * @throws BridgeError when the request needs more than this link's access (code `read_only`), when * the plugin answers `err`, when no reply arrives within [timeoutMs] (code `timeout`), or when the - * link is or becomes closed (code `closed`) + * link is or becomes closed or the gate is closed (code `closed`) */ public fun call( op: String, args: ObjectNode, access: Access, timeoutMs: Long, + gate: Gate = Gate(), ): JsonNode { if (access > this.access) { throw BridgeError("read_only", "'$op' sends input, which a link made for reading never forwards") @@ -91,12 +93,15 @@ public class BridgeLink internal constructor( val request = MAPPER.createObjectNode().put("id", id).put("op", op) request.set("args", args) - try { - send(MAPPER.writeValueAsString(request)) - } catch (e: IOException) { - close() - throw closedError() - } + val sent = + try { + gate.ifOpen { send(MAPPER.writeValueAsString(request)) } + } catch (e: IOException) { + close() + throw closedError() + } + + if (!sent) throw BridgeError("closed", "the MCP endpoint was turned off") return reply.get(timeoutMs, TimeUnit.MILLISECONDS) } catch (e: TimeoutException) { diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Gate.kt b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Gate.kt new file mode 100644 index 00000000..7c0279cc --- /dev/null +++ b/mcp/src/main/kotlin/net/rsprox/mcp/bridge/Gate.kt @@ -0,0 +1,45 @@ +package net.rsprox.mcp.bridge + +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +/** + * Decides whether a request may still act on a client: fork one, kill one or send one a request. + * + * A request can run for minutes, so closing the endpoint cannot wait for it, and its thread cannot be + * stopped from outside. Each act therefore passes the gate at the moment it happens, and [close] waits + * only for the acts that are passing, which are short. Once [close] has returned, no act happens until + * [open]. The standalone server never closes its gate. + */ +public class Gate( + open: Boolean = true, +) { + /** The lock that the acts share and that opening and closing take alone. */ + private val lock = ReentrantReadWriteLock() + + /** Whether acts pass. Guarded by [lock]. */ + private var open = open + + /** Whether acts pass at this moment, which says nothing of the next. */ + public val isOpen: Boolean + get() = lock.read { open } + + /** Run [act] if the gate is open, and keep the gate from closing until it returns. Returns whether it ran. */ + public fun ifOpen(act: () -> Unit): Boolean = + lock.read { + if (open) act() + + open + } + + /** Let acts pass. Idempotent. */ + public fun open() { + lock.write { open = true } + } + + /** Refuse every act from now on, once the acts that are passing have returned. Idempotent. */ + public fun close() { + lock.write { open = false } + } +} diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt index 3d98c1e2..02d795bd 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/Tools.kt @@ -376,7 +376,8 @@ private fun clientTool( result: (ok: ObjectNode, session: Session) -> ToolResult = { ok, _ -> ToolResult.Json(ok) }, ): Tool = Tool(name, "$description $CURSOR_NOTE", schema) { args -> - val session = sessions().resolve(args.text("session")) + val manager = sessions() + val session = manager.resolve(args.text("session")) val requested = request(args) val link = session.link(name, requested.access) val cursor = session.packets.head().seq @@ -386,7 +387,7 @@ private fun clientTool( if (!forwarded.hasNonNull(argument)) forwarded.put(argument, value) } - val ok = link.call(requested.op, forwarded, requested.access, timeoutMs(forwarded)) + val ok = link.call(requested.op, forwarded, requested.access, timeoutMs(forwarded), manager.gate) if (ok !is ObjectNode) throw BridgeError("internal", "the client answered $name with $ok") result(ok.put("cursor", cursor), session) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt index 2170f2aa..8f01eace 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/session/SessionManager.kt @@ -4,6 +4,7 @@ import net.rsprox.mcp.bridge.Access import net.rsprox.mcp.bridge.BridgeHub import net.rsprox.mcp.bridge.BridgeLink import net.rsprox.mcp.bridge.BridgeListener +import net.rsprox.mcp.bridge.Gate import net.rsprox.mcp.packets.PacketTap import net.rsprox.mcp.server.ToolError import net.rsprox.proxy.binary.BinaryHeader @@ -59,6 +60,8 @@ public class SessionManager internal constructor( private val launchTimeoutMs: Long = DEFAULT_LAUNCH_TIMEOUT_MS, /** The longest wait for the plugin of a launched client to say hello. */ private val helloTimeoutMs: Long = DEFAULT_HELLO_TIMEOUT_MS, + /** The gate that a request passes to fork a client, to kill one or to send one a request. */ + internal val gate: Gate = Gate(), ) { /** The sessions in the order they were first launched or attached. Added to under its own monitor. */ private val sessions = CopyOnWriteArrayList() @@ -103,28 +106,34 @@ public class SessionManager internal constructor( /** * Kill the client of a launched session. The session and its packets stay listed and readable. Idempotent. - * Throws [ToolError] for an attached session, whose client is not the MCP server's to kill. + * Throws [ToolError] for an attached session, whose client is not the MCP server's to kill, and when + * the gate is closed. */ public fun stop(session: String?): SessionSnapshot { val resolved = launched(resolve(session), "session_stop") synchronized(launchLock) { - val state = resolved.client - - // Stopped first, so the close of the link below is not mistaken for the client exiting. - resolved.stop("stopped by caller") - - when (state) { - is ClientState.Stopped -> { - // + val stopped = + gate.ifOpen { + val state = resolved.client + + // Stopped first, so the close of the link below is not mistaken for the client exiting. + resolved.stop("stopped by caller") + + when (state) { + is ClientState.Stopped -> { + // + } + is ClientState.Launching -> release(state.launch) + is ClientState.Unbridged -> launcher.kill(state.launch.proxyPort) + is ClientState.Connected -> { + state.link.close() + launcher.kill(state.launch.proxyPort) + } + } } - is ClientState.Launching -> release(state.launch) - is ClientState.Unbridged -> launcher.kill(state.launch.proxyPort) - is ClientState.Connected -> { - state.link.close() - launcher.kill(state.launch.proxyPort) - } - } + + if (!stopped) throw ToolError(ENDPOINT_OFF) } return resolved.snapshot() @@ -250,7 +259,8 @@ public class SessionManager internal constructor( /** * Launch a client of the target for [stopped], or for a new session when it is null, and wait until its * launcher has completed the handshake. A new session is listed once the ports of its launch are reserved. - * Throws [ToolError] when the launch fails or hangs. Must be called with [launchLock] held. + * Throws [ToolError] when the launch fails or hangs, and when the gate is closed. Must be called with + * [launchLock] held. */ private fun launch( stopped: LaunchedSession?, @@ -258,6 +268,9 @@ public class SessionManager internal constructor( ): LaunchedSession { if (hungLaunch) throw ToolError(HUNG_LAUNCH) + // Refused before anything is reserved or listed. The gate decides again where the client is forked. + if (!gate.isOpen) throw ToolError(ENDPOINT_OFF) + val reservation = reserve(target) val session = stopped ?: register { id -> LaunchedSession(id, target) } val launch = Launch(session.nextGeneration(), reservation.proxyPort, reservation.httpPort) @@ -290,7 +303,9 @@ public class SessionManager internal constructor( /** * Fork the client on a thread of its own and wait until its launcher has completed the handshake. - * Returns the reason when the launch failed or hung, and null when it succeeded. + * Returns the reason when the launch failed or hung, and null when it succeeded. The launch is handed + * to the proxy only through the open gate. The proxy cannot be told to give a launch up, so a launch + * that returns after the gate closed counts as failed, which has its client killed. * Must be called with [launchLock] held. */ private fun fork( @@ -309,13 +324,14 @@ public class SessionManager internal constructor( }, "mcp-launch-${session.id}") thread.isDaemon = true - thread.start() + if (!gate.ifOpen { thread.start() }) return ENDPOINT_OFF + val exited = awaitLaunch(thread, reservation.launcherExited) hungLaunch = thread.isAlive return when { - !hungLaunch -> failure.get()?.let(::rootMessage) + !hungLaunch -> failure.get()?.let(::rootMessage) ?: ENDPOINT_OFF.takeUnless { gate.isOpen } exited -> "the launcher exited before completing its handshake" else -> "launcher never completed its handshake" } @@ -440,6 +456,9 @@ public class SessionManager internal constructor( /** The reason of a session whose client the proxy reported gone. */ private const val CLIENT_CLOSED = "the client was closed" + /** The refusal of a request that would act on a client through a closed gate. */ + private const val ENDPOINT_OFF = "the MCP endpoint was turned off" + /** The refusal of every launch that follows one whose launcher never returned. */ private const val HUNG_LAUNCH = "an earlier launch never completed its handshake; restart the rsprox MCP process" diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt index 637a0e93..59215a1f 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt @@ -41,7 +41,7 @@ class McpExtensionTest { val served = ToolServer { sessions -> port = freePort() - checkNotNull(serveIfEnabled(enabled = true, port) { sessions }) + port to checkNotNull(serveIfEnabled(enabled = true, port) { sessions }) } served.use { server -> assertEquals(port, server.port) } @@ -75,7 +75,7 @@ class McpExtensionTest { val served = ToolServer { sessions -> port = freePort() - checkNotNull(serveOrNull(port) { sessions }) + port to checkNotNull(serveOrNull(port) { sessions }) } served.use { server -> diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ClosedGateTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ClosedGateTest.kt new file mode 100644 index 00000000..5a516d0e --- /dev/null +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ClosedGateTest.kt @@ -0,0 +1,138 @@ +package net.rsprox.mcp.server + +import net.rsprox.mcp.session.FakeLauncher +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ClosedGateTest { + private val server = ToolServer() + + // Every request the plugin received, as its op. + private val received = CopyOnWriteArrayList() + + @AfterTest + fun cleanUp() { + server.close() + } + + private fun connect() { + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT) + plugin.read() + plugin.serve { op, _ -> + received += op + """"ok":{}""" + } + + assertEquals("connected", server.manager.start(null, "s1", 10_000).state) + } + + private fun states(): List> = server.manager.list().map { it.state to it.reason } + + @Test + fun `a session_start that arrives at a closed gate reserves no ports and lists no session`() { + server.gate.close() + + assertEquals( + "the MCP endpoint was turned off", + server.error("session_start", """{"target":"My Server","wait_ms":0}"""), + ) + assertEquals(emptyList(), server.launcher.reserved) + assertEquals(emptyList(), states()) + } + + @Test + fun `a session_start that waits its turn while the gate closes forks no client`() { + val forking = CountDownLatch(1) + val release = CountDownLatch(1) + server.launcher.launch = { + forking.countDown() + release.await() + } + + val first = CompletableFuture.supplyAsync { server.error("session_start", """{"wait_ms":0}""") } + assertEquals(true, forking.await(10, TimeUnit.SECONDS)) + val second = CompletableFuture.supplyAsync { server.error("session_start", """{"wait_ms":0}""") } + + server.gate.close() + release.countDown() + + assertEquals("session s1 failed to launch: the MCP endpoint was turned off", first.get(10, TimeUnit.SECONDS)) + assertEquals("the MCP endpoint was turned off", second.get(10, TimeUnit.SECONDS)) + assertEquals(1, server.launcher.reserved.size) + assertEquals(listOf(FakeLauncher.FIRST_PROXY_PORT), server.launcher.killed) + assertEquals(listOf("stopped" to "the MCP endpoint was turned off"), states()) + } + + @Test + fun `a gate that closes once the ports of a launch are reserved forks no client`() { + var forked = false + server.launcher.onReserve = { server.gate.close() } + server.launcher.launch = { forked = true } + + assertEquals( + "session s1 failed to launch: the MCP endpoint was turned off", + server.error("session_start", """{"wait_ms":0}"""), + ) + assertEquals(false, forked) + assertEquals(listOf(FakeLauncher.FIRST_PROXY_PORT), server.launcher.killed) + assertEquals(listOf("stopped" to "the MCP endpoint was turned off"), states()) + } + + @Test + fun `a client tool sends the plugin nothing through a closed gate, and works again once it opens`() { + connect() + server.gate.close() + + assertEquals( + "closed: the MCP endpoint was turned off", + server.error("client_click", """{"session":"s1","x":10,"y":20}"""), + ) + assertEquals(emptyList(), received) + + server.gate.open() + server.call("client_click", """{"session":"s1","x":10,"y":20}""") + + assertEquals(listOf("click"), received) + } + + @Test + fun `session_stop kills no client through a closed gate`() { + connect() + server.gate.close() + + assertEquals("the MCP endpoint was turned off", server.error("session_stop", """{"session":"s1"}""")) + assertEquals(emptyList(), server.launcher.killed) + assertEquals(listOf("connected" to null), states()) + } + + @Test + fun `closing the gate waits for the act that is passing it and then refuses the next`() { + val passing = CountDownLatch(1) + val release = CountDownLatch(1) + val act = + CompletableFuture.supplyAsync { + server.gate.ifOpen { + passing.countDown() + release.await() + } + } + + assertEquals(true, passing.await(10, TimeUnit.SECONDS)) + val closed = CompletableFuture.runAsync { server.gate.close() } + + assertFailsWith { closed.get(200, TimeUnit.MILLISECONDS) } + release.countDown() + closed.get(10, TimeUnit.SECONDS) + + assertEquals(true, act.get(10, TimeUnit.SECONDS)) + assertEquals(false, server.gate.ifOpen { error("no act passes a closed gate") }) + } +} diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt index 07d6d703..6f2591d8 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/ToolServer.kt @@ -1,6 +1,7 @@ package net.rsprox.mcp.server import com.fasterxml.jackson.databind.JsonNode +import net.rsprox.mcp.bridge.Gate import net.rsprox.mcp.bridge.TestHub import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.session.FakeLauncher @@ -14,20 +15,24 @@ import kotlin.test.assertTrue /** * The tools over a fake launcher and a started bridge hub, called over HTTP the way an MCP client calls them. - * [serve] starts the endpoint over the sessions, on a free port unless a test says otherwise. + * [serve] starts the endpoint over the sessions and returns its port with what stops it. The port is a + * free one unless a test says otherwise. The sessions act on clients through [gate]. */ internal class ToolServer( - serve: (SessionManager) -> McpHttpServer = { sessions -> - McpHttpServer(0, tools { sessions }, "1.2.3").also { it.start() } + val gate: Gate = Gate(), + serve: (SessionManager) -> Pair = { sessions -> + val server = McpHttpServer(0, tools { sessions }, "1.2.3").also { it.start() } + + server.localPort to server }, ) : AutoCloseable { val launcher = FakeLauncher() val hub = TestHub() - val manager = SessionManager(launcher, TapSettingSetStore, hub.hub) - private val server = serve(manager) + val manager = SessionManager(launcher, TapSettingSetStore, hub.hub, gate = gate) + private val served = serve(manager) private val http = HttpClient.newHttpClient() - val port: Int get() = server.localPort + val port: Int get() = served.first /** Sends one JSON-RPC request and returns its `result`. */ fun rpc( @@ -78,7 +83,7 @@ internal class ToolServer( fun rows(arguments: String): List = call("packets_read", arguments).lines().drop(1) override fun close() { - server.close() + served.second.close() hub.close() } diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt index 2ec8ea52..0ce4bfed 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/session/SessionFixtures.kt @@ -49,6 +49,9 @@ internal class FakeLauncher : ClientLauncher { // A client that goes away is killed from the bridge's reader thread. val killed = CopyOnWriteArrayList() var launch: () -> Unit = {} + + // Runs when the ports of a launch are reserved, before its client is forked. + var onReserve: () -> Unit = {} var launcherExited: () -> Boolean = { false } var bridged = true @@ -60,6 +63,7 @@ internal class FakeLauncher : ClientLauncher { override fun reserve(target: ProxyTargetConfig): Reservation { reserved += target val earlier = reserved.size - 1 + onReserve() return Reservation( FIRST_PROXY_PORT + earlier, From 5f4f057524040b8b485a9eab39b39592ab528a14 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:48:26 +0200 Subject: [PATCH 67/72] fix(mcp): end the threads of the endpoint when it is closed Closing the HTTP server closed the port and every connection, but left the pool that runs the requests alone, so its idle threads lingered for a minute. An endpoint that is turned off and on would stack one pool on the next. The pool is now shut down with the server, which lets a request that still runs finish and starts no other. The tests also pin what the close already did: a connection is refused and a request that runs gets no answer. Co-Authored-By: Claude Opus 5.5 --- .../net/rsprox/mcp/server/McpHttpServer.kt | 19 +++++--- .../rsprox/mcp/server/McpHttpServerTest.kt | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt index d7a759dd..152030de 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/server/McpHttpServer.kt @@ -378,6 +378,12 @@ public class McpHttpServer( /** The HTTP server, or null before [start]. */ private var server: HttpServer? = null + /** The pool whose threads run the requests. Its idle threads end when it is shut down. */ + private val workers = + Executors.newCachedThreadPool { runnable -> + Thread(runnable, "mcp-http").apply { isDaemon = true } + } + /** The port the server listens on, which differs from the requested one when that was 0. */ internal val localPort: Int get() = checkNotNull(server) { "the server has not been started" }.address.port @@ -388,11 +394,7 @@ public class McpHttpServer( */ public fun start() { val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0) - server.executor = - Executors.newCachedThreadPool { runnable -> - Thread(runnable, "mcp-http").apply { isDaemon = true } - } - + server.executor = workers server.createContext(PATH, ::handle) server.start() this.server = server @@ -432,9 +434,14 @@ public class McpHttpServer( } } - /** Stop serving at once, if the server was started. */ + /** + * Stop serving at once, if the server was started: the port is closed and so is every connection, + * so a request that still runs has nobody to answer. Its thread is not stopped and ends when its + * tool returns. A closed server is not started again. + */ override fun close() { server?.stop(0) + workers.shutdown() } private companion object { diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt index 4a8a719a..e035a65c 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/server/McpHttpServerTest.kt @@ -1,14 +1,22 @@ package net.rsprox.mcp.server +import net.rsprox.mcp.bridge.awaitTrue +import java.io.IOException import java.net.BindException +import java.net.ConnectException import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertIs class McpHttpServerTest { private val echo = @@ -129,4 +137,44 @@ class McpHttpServerTest { assertFailsWith { second.start() } } + + @Test + fun `a closed server refuses a connection`() { + val ping = request().POST(HttpRequest.BodyPublishers.ofString("""{"jsonrpc":"2.0","id":1,"method":"ping"}""")) + + server.close() + + assertFailsWith { send(ping) } + } + + @Test + fun `a request that runs when the server closes is not answered and leaves no thread behind`() { + val running = CompletableFuture() + val release = CountDownLatch(1) + val slow = + Tool("slow", "Returns once the test lets it", echo.inputSchema) { + running.complete(Thread.currentThread()) + release.await() + ToolResult.Text("late") + } + + val closing = McpHttpServer(0, listOf(slow), "1.2.3").also { it.start() } + val call = + HttpRequest + .newBuilder(URI("http://127.0.0.1:${closing.localPort}/mcp")) + .POST( + HttpRequest.BodyPublishers.ofString( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"slow"}}""", + ), + ).build() + + val answer = client.sendAsync(call, HttpResponse.BodyHandlers.ofString()) + val worker = running.get(10, TimeUnit.SECONDS) + + closing.close() + release.countDown() + + assertIs(assertFailsWith { answer.get(10, TimeUnit.SECONDS) }.cause) + awaitTrue("the thread of the request has ended") { !worker.isAlive } + } } From c35825a3dd8b34ac1c076510eedc1a3005793a73 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:49:10 +0200 Subject: [PATCH 68/72] feat(proxy): let the GUI ask its extensions to serve and to stop serving An extension could only be started, once. It can now be asked to serve what it offers and to stop serving it while the GUI runs, and it answers a request to serve with the reason when it cannot. Both default to doing nothing. startAll returns the extensions that started, which is what the GUI asks. One that failed to start is not among them, and with none the answer says that nothing is served. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/proxy/ProxyExtension.kt | 54 +++++++++++++++- .../net/rsprox/proxy/ProxyExtensionTest.kt | 61 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt index ef1889a3..ee40c818 100644 --- a/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt +++ b/proxy/src/main/kotlin/net/rsprox/proxy/ProxyExtension.kt @@ -12,15 +12,27 @@ public interface ProxyExtension { /** Called once, after [service] has started. */ public fun start(service: ProxyService) + /** + * Serve what the extension offers to other programs, from this call on. Returns null once it is + * served, and otherwise the reason it is not, in words for the user. Called on the Swing thread. + */ + public fun serve(): String? = null + + /** Stop serving it, from this call on. Called on the Swing thread. */ + public fun stopServing() { + } + public companion object { private val logger = InlineLogger() /** Start every extension on the classpath. One that fails is logged and skipped. */ - public fun startAll(service: ProxyService) { + public fun startAll(service: ProxyService): StartedExtensions { + val started = ArrayList() try { for (extension in ServiceLoader.load(ProxyExtension::class.java)) { try { extension.start(service) + started += extension } catch (t: Throwable) { logger.error(t) { "Unable to start ${extension.javaClass.name}" } } @@ -28,6 +40,46 @@ public interface ProxyExtension { } catch (e: ServiceConfigurationError) { logger.error(e) { "Unable to load the proxy extensions" } } + return StartedExtensions(started) + } + } +} + +/** The extensions that started, which the GUI switches on and off while it runs. */ +public class StartedExtensions internal constructor( + private val extensions: List, +) { + /** Ask every extension to serve. Returns null once all do, and otherwise the reason of the first that does not. */ + public fun serve(): String? { + if (extensions.isEmpty()) { + return "Nothing is served, because no extension of RSProx is running. " + + "The log names one that failed to start." + } + for (extension in extensions) { + val refusal = + try { + extension.serve() + } catch (t: Throwable) { + logger.error(t) { "Unable to serve ${extension.javaClass.name}" } + "${extension.javaClass.name} failed: ${t.message}" + } + if (refusal != null) return refusal } + return null + } + + /** Ask every extension to stop serving. One that fails is logged and skipped. */ + public fun stopServing() { + for (extension in extensions) { + try { + extension.stopServing() + } catch (t: Throwable) { + logger.error(t) { "Unable to stop serving ${extension.javaClass.name}" } + } + } + } + + private companion object { + private val logger = InlineLogger() } } diff --git a/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt b/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt index 050fe2b0..a0cfc2d0 100644 --- a/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt +++ b/proxy/src/test/kotlin/net/rsprox/proxy/ProxyExtensionTest.kt @@ -1,12 +1,18 @@ package net.rsprox.proxy import io.netty.buffer.ByteBufAllocator +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull /** Extensions that the test registers through `META-INF/services`. */ class FailingExtension : ProxyExtension { override fun start(service: ProxyService): Unit = throw IllegalStateException("cannot start") + + override fun serve(): String = error("an extension that failed to start is not asked to serve") + + override fun stopServing(): Unit = error("an extension that failed to start is not asked to stop serving") } class RecordingExtension : ProxyExtension { @@ -14,12 +20,31 @@ class RecordingExtension : ProxyExtension { started += service } + override fun serve(): String? { + asked += "serve" + + return refusal + } + + override fun stopServing() { + asked += "stop serving" + } + companion object { val started = ArrayList() + val asked = ArrayList() + var refusal: String? = null } } class ProxyExtensionTest { + @BeforeTest + fun reset() { + RecordingExtension.started.clear() + RecordingExtension.asked.clear() + RecordingExtension.refusal = null + } + @Test fun `an extension that fails to start does not keep the next one from starting`() { val service = ProxyService(ByteBufAllocator.DEFAULT) @@ -28,4 +53,40 @@ class ProxyExtensionTest { assertEquals(listOf(service), RecordingExtension.started) } + + @Test + fun `the started extensions are asked to serve and to stop serving, and one that failed to start is not`() { + val extensions = ProxyExtension.startAll(ProxyService(ByteBufAllocator.DEFAULT)) + + assertNull(extensions.serve()) + extensions.stopServing() + + assertEquals(listOf("serve", "stop serving"), RecordingExtension.asked) + } + + @Test + fun `serving reports why an extension cannot serve`() { + val extensions = ProxyExtension.startAll(ProxyService(ByteBufAllocator.DEFAULT)) + RecordingExtension.refusal = "127.0.0.1:43580 cannot be bound" + + assertEquals("127.0.0.1:43580 cannot be bound", extensions.serve()) + } + + @Test + fun `serving reports an extension that throws instead of throwing`() { + val extensions = StartedExtensions(listOf(FailingExtension())) + + assertEquals( + "net.rsprox.proxy.FailingExtension failed: an extension that failed to start is not asked to serve", + extensions.serve(), + ) + } + + @Test + fun `serving with no extension started reports that nothing is served`() { + assertEquals( + "Nothing is served, because no extension of RSProx is running. The log names one that failed to start.", + StartedExtensions(emptyList()).serve(), + ) + } } From 173c6c6d2ac0476eebd1140e33546c3dd8d03a1a Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:53:04 +0200 Subject: [PATCH 69/72] feat(mcp): turn the endpoint of the GUI on and off while the GUI runs The extension served the endpoint at start-up or not at all. A switch now answers the GUI's requests to serve and to stop serving. On binds the port and, the first time, builds the sessions, the bridge hub and the client listener. It answers with the reason when the port is taken or the sessions cannot be built, and leaves nothing bound. Off closes the port and every connection and closes the gate, so a request that still runs can no longer fork a client, kill one or send one a request. The sessions, the hub and the listener stay in memory until the GUI exits, and the packet taps keep recording: the proxy cannot take a tap or a listener off, and closing the hub would kill the clients that the endpoint launched. On again serves the same sessions, with their logs and cursors. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/net/rsprox/mcp/McpExtension.kt | 177 ++++++++---- mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt | 7 +- .../kotlin/net/rsprox/mcp/McpExtensionTest.kt | 251 +++++++++++++++--- 3 files changed, 348 insertions(+), 87 deletions(-) diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt index d5be1e18..6c49f461 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpExtension.kt @@ -1,6 +1,7 @@ package net.rsprox.mcp import com.github.michaelbull.logging.InlineLogger +import net.rsprox.mcp.bridge.Gate import net.rsprox.mcp.bridge.Rendering import net.rsprox.mcp.server.McpHttpServer import net.rsprox.mcp.server.ToolError @@ -13,78 +14,151 @@ import java.util.concurrent.atomic.AtomicReference /** * Serves the MCP endpoint from inside the rsprox GUI, which finds this class through `META-INF/services`. - * Every client that is launched from the GUI becomes an attached session. The proxy keeps the filter and - * setting stores of the GUI, so the packet logs hold what the GUI shows. + * Every client that is launched from the GUI while the sessions exist becomes an attached session. The + * proxy keeps the filter and setting stores of the GUI, so the packet logs hold what the GUI shows. */ public class McpExtension : ProxyExtension { + /** The switch of the endpoint, or null before [start]. Set on the main thread, read on the Swing thread. */ + @Volatile + private var switch: EndpointSwitch? = null + /** Serve the endpoint on the configured port, if the user turned it on and the port is free. */ override fun start(service: ProxyService) { - serveIfEnabled(service.getMcpEnabled(), service.getMcpPort()) { opened -> - sessionManager(service, sideloadDir = null, Rendering.GPU, service.getMcpPluginEnabled(), opened) - } + val gate = Gate() + val switch = + EndpointSwitch(service.getMcpPort(), gate) { opened -> + sessionManager(service, sideloadDir = null, Rendering.GPU, service.getMcpPluginEnabled(), opened, gate) + } + + this.switch = switch + switch.atStartUp(service.getMcpEnabled()) + } + + /** Serve the endpoint from this call on. Returns null once it is served, and otherwise why it is not. */ + override fun serve(): String? = checkNotNull(switch) { "the extension has not been started" }.serve() + + /** Stop serving the endpoint from this call on. */ + override fun stopServing() { + switch?.stopServing() } } /** - * Serve the endpoint on [port] as [serveOrNull] does when [enabled] says the user turned it on. Otherwise - * bind nothing, build no sessions and return null, since the endpoint has no password. + * Turns the endpoint of the GUI on and off while the GUI runs. + * + * On binds [port] and serves the sessions over HTTP. The first time, it also builds the sessions, with + * the bridge hub and the listener that attaches a session to every client the proxy launches from then + * on. A client that was already open is not attached, because the proxy asks its listeners only at a + * launch. + * + * Off closes the port and every connection, and closes [gate], so that a request that still runs can no + * longer fork a client, kill one or send one a request. Off does not take the sessions down. They, the + * hub and the listener stay in memory until the GUI exits, and the packet taps on the open clients keep + * recording, for three reasons. The proxy cannot take a packet tap off a client that runs, or a listener + * off itself. Closing the hub closes every link, and a closed link has the client behind it killed, so + * turning the endpoint off would close the clients it launched. And a tap that dropped packets while + * the endpoint is off would leave a session that lies when it is listed again: its login state and its + * tick would be stale and its log would have a hole. Nothing outside the process reaches what is kept, + * since the port is its only door. The GUI holds the same packets either way. + * + * On again serves the same sessions on a new HTTP server, so they are listed with their logs and cursors. */ -internal fun serveIfEnabled( - enabled: Boolean, - port: Int, - sessions: (opened: MutableList) -> SessionManager, -): McpHttpServer? { - if (!enabled) { - endpointLogger.info { - "The MCP endpoint is off. Turn it on with File > Serve MCP Endpoint, or with mcp.enabled in " + - "proxy.properties, and restart rsprox." - } +internal class EndpointSwitch( + /** The loopback port of the endpoint. */ + private val port: Int, + /** The gate that the sessions act on clients through. It is open exactly while the endpoint is served. */ + private val gate: Gate, + /** + * Builds the sessions, the first time the endpoint is served. It may add what must be closed when a + * later step fails to the list it is given. + */ + private val build: (opened: MutableList) -> SessionManager, +) { + /** The sessions, or null until the endpoint has been served once. They outlive every HTTP server. */ + private val sessions = AtomicReference() - return null + /** The HTTP server, or null while the endpoint is off. Guarded by the monitor of the switch. */ + private var http: McpHttpServer? = null + + init { + gate.close() } - return serveOrNull(port, sessions) -} + /** Serve the endpoint if [enabled] says the user turned it on, and log why it is not served otherwise. */ + fun atStartUp(enabled: Boolean) { + if (!enabled) { + logger.info { + "The MCP endpoint is off. Turn it on with File > Serve MCP Endpoint, or with mcp.enabled in " + + "proxy.properties." + } -/** - * Bind the endpoint to [port] and then build the sessions it serves, which may add what must be closed - * on a failure to the list they are given. Returns null when the port is taken or the sessions cannot be - * built. Either is logged and neither is thrown, since the endpoint is no reason for its host to stop. - */ -internal fun serveOrNull( - port: Int, - sessions: (opened: MutableList) -> SessionManager, -): McpHttpServer? { - val built = AtomicReference() - val http = endpoint(port, built) - - try { - http.start() - } catch (e: IOException) { - endpointLogger.warn { - "The MCP endpoint is not served: 127.0.0.1:$port cannot be bound (${e.message}). Another rsprox or " + - "the standalone MCP server may hold it. Set mcp.port or mcp.enabled in proxy.properties." + return } - return null + serve()?.let { refusal -> logger.warn { refusal } } } - try { - built.set( - closingOnFailure { opened -> - opened += http - sessions(opened) - }, - ) - } catch (e: Exception) { - endpointLogger.error(e) { "The MCP endpoint could not start and is not served" } + /** + * Serve the endpoint from this call on. Returns null once it is served, and otherwise the reason it + * is not, in words for the user: the port is taken, or the sessions cannot be built. Neither leaves + * anything bound, and neither is thrown, since the endpoint is no reason for its host to stop. + * Idempotent. Safe to call from any thread, and quick enough for the Swing thread. + */ + @Synchronized + fun serve(): String? { + if (http != null) return null + + val server = endpoint(port, sessions) + + try { + server.start() + } catch (e: IOException) { + return "The MCP endpoint is not served: 127.0.0.1:$port cannot be bound (${e.message}). Another " + + "rsprox or the standalone MCP server may hold it. Close that one, or set mcp.port in " + + "proxy.properties." + } + + if (sessions.get() == null) { + try { + sessions.set( + closingOnFailure { opened -> + opened += server + build(opened) + }, + ) + } catch (e: Exception) { + logger.error(e) { "The MCP endpoint could not start and is not served" } + + return "The MCP endpoint is not served: it could not start (${e.message}). The log has the details." + } + } + + gate.open() + http = server + logger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } return null } - endpointLogger.info { "MCP endpoint listening on http://127.0.0.1:$port/mcp" } + /** + * Stop serving from this call on: no request that still runs acts on a client once this returns, and + * no connection is accepted or answered. The sessions are kept, as the class says. Idempotent. Safe + * to call from any thread, and quick enough for the Swing thread. + */ + @Synchronized + fun stopServing() { + val server = http ?: return + + http = null + gate.close() + server.close() + logger.info { "The MCP endpoint is off. Its sessions are kept until rsprox exits." } + } - return http + private companion object { + /** The logger of the endpoint. */ + private val logger = InlineLogger() + } } /** Build the endpoint on [port], whose tools refuse every call until [sessions] holds the sessions. */ @@ -97,6 +171,3 @@ internal fun endpoint( tools { sessions.get() ?: throw ToolError("rsprox is still starting; try again shortly") }, System.getenv("APP_VERSION") ?: "dev", ) - -/** The logger of the endpoint. */ -private val endpointLogger = InlineLogger() diff --git a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt index e6c16b71..a4e57f91 100644 --- a/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt +++ b/mcp/src/main/kotlin/net/rsprox/mcp/McpMain.kt @@ -11,6 +11,7 @@ import com.github.michaelbull.logging.InlineLogger import io.netty.buffer.ByteBufAllocator import net.rsprox.mcp.bridge.BridgeHub import net.rsprox.mcp.bridge.BridgeJar +import net.rsprox.mcp.bridge.Gate import net.rsprox.mcp.bridge.Rendering import net.rsprox.mcp.packets.TapSettingSetStore import net.rsprox.mcp.packets.UnfilteredFilterSetStore @@ -121,7 +122,8 @@ public class McpCommand : CliktCommand(name = "mcp") { * Start the bridge hub and build the sessions of a started proxy, with the stores the proxy holds at * this moment. The sessions hear of every client the proxy launches and closes. With [plugin] false, * launched clients get no bridge plugin, so only their packets can be read. Adds what must be closed - * when a later step fails to [opened]. + * when a later step fails to [opened]. A request acts on a client only through [gate], which nothing + * closes unless the caller does. */ internal fun sessionManager( service: ProxyService, @@ -129,6 +131,7 @@ internal fun sessionManager( rendering: Rendering, plugin: Boolean, opened: MutableList, + gate: Gate = Gate(), ): SessionManager { val launcher = ProxyServiceLauncher(service, if (plugin) BridgeJar(sideloadDir) else null) @@ -142,7 +145,7 @@ internal fun sessionManager( // The proxy's own hook kills the clients; this one removes the rendezvous file they dial through. Runtime.getRuntime().addShutdownHook(Thread(hub::close, "mcp-bridge-shutdown")) - val manager = SessionManager(launcher, service.settingsStore, hub) + val manager = SessionManager(launcher, service.settingsStore, hub, gate = gate) service.addClientListener(ClientWatch(manager)) return manager diff --git a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt index 59215a1f..87c8e54e 100644 --- a/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt +++ b/mcp/src/test/kotlin/net/rsprox/mcp/McpExtensionTest.kt @@ -1,18 +1,62 @@ package net.rsprox.mcp +import net.rsprox.mcp.bridge.BridgeError +import net.rsprox.mcp.bridge.Gate +import net.rsprox.mcp.server.McpDispatcher import net.rsprox.mcp.server.McpHttpServer import net.rsprox.mcp.server.ToolServer +import net.rsprox.mcp.server.tools +import net.rsprox.mcp.session.FakeLauncher +import net.rsprox.mcp.session.loginHeader +import net.rsprox.mcp.session.target import net.rsprox.proxy.ProxyExtension +import net.rsprox.shared.StreamDirection +import net.rsprox.shared.property.ChildProperty +import net.rsprox.shared.property.RootProperty +import java.io.IOException +import java.net.ConnectException import java.net.ServerSocket import java.util.ServiceLoader +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNull class McpExtensionTest { + private lateinit var switch: EndpointSwitch + private fun freePort(): Int = ServerSocket(0).use { it.localPort } + /** The tools over a switch that is off. Closing the server turns the switch off. */ + private fun switched(): ToolServer { + val gate = Gate() + + // The port is picked once the test server holds its other ports, so that none of them takes it. + return ToolServer(gate) { sessions -> + val port = freePort() + switch = EndpointSwitch(port, gate) { sessions } + + port to AutoCloseable { switch.stopServing() } + } + } + + private fun ToolServer.refuses() { + assertFailsWith { rpc("ping", "{}") } + } + + private fun packet(prot: String): RootProperty = + object : RootProperty { + override val prot = prot + override val children = mutableListOf>() + } + @Test fun `the GUI finds the extension among the proxy extensions on its classpath`() { val found = ServiceLoader.load(ProxyExtension::class.java).map { it.javaClass } @@ -21,69 +65,212 @@ class McpExtensionTest { } @Test - fun `an endpoint that is turned off is not served and builds no sessions`() { + fun `an endpoint that is off at start-up binds nothing and builds no sessions`() { + val port = freePort() var built = false - - val served = - serveIfEnabled(enabled = false, freePort()) { + val off = + EndpointSwitch(port, Gate()) { built = true error("no session is built for an endpoint that is off") } - assertNull(served) + off.atStartUp(enabled = false) + off.stopServing() + + ServerSocket(port).close() assertFalse(built) } @Test - fun `an endpoint that is turned on serves the tools on its port`() { - var port = 0 + fun `an endpoint that is on at start-up serves the tools on its port`() { + switched().use { server -> + switch.atStartUp(enabled = true) + + assertEquals( + """{"sessions":[],"targets":["Old School RuneScape","My Server"]}""", + server.call("session_list"), + ) + } + } + + @Test + fun `turning the endpoint on serves the tools and lists a client that is launched after it`() { + switched().use { server -> + server.refuses() + + assertNull(switch.serve()) + server.manager.attach(43701, target(1, "Local")) + + assertEquals( + """{"sessions":[{"session":"s1","kind":"attached","target":"Local","access":"read",""" + + """"state":"attached","proxyPort":43701,"cursor":1}],""" + + """"targets":["Old School RuneScape","My Server"]}""", + server.call("session_list"), + ) + } + } + + @Test + fun `turning the endpoint off refuses a connection and keeps a request that still runs from forking a client`() { + switched().use { server -> + val reserved = CountDownLatch(1) + val off = CountDownLatch(1) + var forked = false + server.launcher.onReserve = { + reserved.countDown() + off.await() + } + server.launcher.launch = { forked = true } + switch.serve() + + val running = CompletableFuture.runAsync { server.rpc("tools/call", """{"name":"session_start"}""") } + assertEquals(true, reserved.await(10, TimeUnit.SECONDS)) + switch.stopServing() + off.countDown() + + assertIs(assertFailsWith { running.get(10, TimeUnit.SECONDS) }.cause) + server.refuses() + assertFalse(forked) + assertEquals( + listOf("stopped" to "the MCP endpoint was turned off"), + server.manager.list().map { it.state to it.reason }, + ) + } + } - val served = - ToolServer { sessions -> - port = freePort() - port to checkNotNull(serveIfEnabled(enabled = true, port) { sessions }) + @Test + fun `a tool that runs after the endpoint is turned off sends the client nothing`() { + switched().use { server -> + val received = CopyOnWriteArrayList() + switch.serve() + server.call("session_start", """{"target":"My Server","wait_ms":0}""") + val plugin = server.hub.dial(FakeLauncher.FIRST_HTTP_PORT) + plugin.read() + plugin.serve { op, _ -> + received += op + """"ok":{}""" } + server.call("session_start", """{"session":"s1","wait_ms":10000}""") + val click = tools { server.manager }.single { it.name == "client_click" } + val arguments = McpDispatcher.MAPPER.createObjectNode().put("x", 10).put("y", 20) + + switch.stopServing() - served.use { server -> assertEquals(port, server.port) } + assertEquals("closed", assertFailsWith { click.run(arguments) }.code) + assertEquals(emptyList(), received) + assertEquals(emptyList(), server.launcher.killed) + } } @Test - fun `a port that is taken leaves no endpoint, builds no sessions and throws nothing`() { + fun `turning the endpoint on again lists the earlier session with its log and what it recorded while off`() { + switched().use { server -> + switch.serve() + val login = checkNotNull(server.manager.attach(43701, target(1, "Local"))).forSession(loginHeader) + login.onLogin(loginHeader) + login.onPacketDirection(StreamDirection.SERVER_TO_CLIENT) + login.onTranscribe(4, packet("REBUILD_NORMAL")) + val served = server.call("packets_read", """{"session":"s1"}""") + + switch.stopServing() + login.onTranscribe(5, packet("UPDATE_STAT")) + assertNull(switch.serve()) + + assertEquals( + listOf( + """{"next":3,"head":3,"dropped":0,"timedOut":false,"count":3}""", + "1 L0 T0 P CLIENT_ATTACHED proxyPort=43701", + "2 L1 T0 P LOGIN revision=235 world=301", + "3 L1 T4 S REBUILD_NORMAL [rebuild_normal] ", + ), + served.lines(), + ) + assertEquals(listOf("4 L1 T5 S UPDATE_STAT [update_stat] "), server.rows("""{"session":"s1","after":3}""")) + assertEquals(4, server.callJson("session_list").get("sessions").single().get("cursor").asInt()) + } + } + + @Test + fun `turning the endpoint on while its port is taken says why, builds no sessions and binds nothing`() { val holder = McpHttpServer(0, emptyList(), "1.2.3").also { it.start() } + val port = holder.localPort + var built = false + val taken = + EndpointSwitch(port, Gate()) { + built = true + error("no session is built behind a port that is taken") + } try { - assertNull(serveOrNull(holder.localPort) { error("no session is built behind a port that is taken") }) + val refusal = checkNotNull(taken.serve()) + + // The reason of the operating system sits between the two halves. + assertEquals( + "The MCP endpoint is not served: 127.0.0.1:$port cannot be bound", + refusal.substringBefore(" ("), + ) + assertEquals( + "Another rsprox or the standalone MCP server may hold it. Close that one, or set mcp.port " + + "in proxy.properties.", + refusal.substringAfter("). "), + ) + assertFalse(built) } finally { holder.close() } + + ServerSocket(port).close() } @Test - fun `sessions that cannot be built leave no endpoint, free the port and throw nothing`() { - val port = freePort() + fun `sessions that cannot be built leave no endpoint and free the port, and the next turn builds them`() { + switched().use { server -> + val port = server.port + var fail = true + val failing = + EndpointSwitch(port, server.gate) { + if (fail) throw IllegalStateException("the bridge hub could not start") - assertNull(serveOrNull(port) { throw IllegalStateException("the bridge hub could not start") }) + server.manager + } - ServerSocket(port).close() + assertEquals( + "The MCP endpoint is not served: it could not start (the bridge hub could not start). " + + "The log has the details.", + failing.serve(), + ) + ServerSocket(port).close() + + fail = false + assertNull(failing.serve()) + server.call("session_list") + failing.stopServing() + } } @Test - fun `a free port serves the tools over the sessions that were built`() { - var port = 0 + fun `turning the endpoint on and off again and again ends in the last state`() { + switched().use { server -> + switch.serve() + switch.serve() + switch.stopServing() + switch.stopServing() + switch.serve() + server.call("session_list") - // The port is picked once the test server holds its other ports, so that none of them takes it. - val served = - ToolServer { sessions -> - port = freePort() - port to checkNotNull(serveOrNull(port) { sessions }) - } + val clicks = + (1..8).map { thread -> + CompletableFuture.runAsync { + repeat(25) { click -> if ((thread + click) % 2 == 0) switch.serve() else switch.stopServing() } + } + } + clicks.forEach { it.get(30, TimeUnit.SECONDS) } - served.use { server -> - assertEquals(port, server.port) - assertEquals( - """{"sessions":[],"targets":["Old School RuneScape","My Server"]}""", - server.call("session_list"), - ) + switch.stopServing() + server.refuses() + + assertNull(switch.serve()) + server.call("session_list") } } } From 90622d04b1dc37aa319b48a20c8c7a0a1fc79887 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:53:57 +0200 Subject: [PATCH 70/72] feat(gui): make Serve MCP Endpoint take effect without a restart Ticking the item serves the endpoint at once and unticking it stops serving at once, through the extensions that started. The choice is saved once it holds. When the endpoint cannot be served, the item goes back to unticked, nothing is saved and a dialog gives the reason, such as a port that is taken. The dialog that asked for a restart is gone. The tooltip now says what is kept while the endpoint is off. Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/net/rsprox/gui/App.kt | 22 ++++--- .../net/rsprox/gui/McpEndpointNotice.kt | 39 ++++++++----- .../kotlin/net/rsprox/gui/ProxyToolGui.kt | 4 +- .../net/rsprox/gui/McpEndpointNoticeTest.kt | 58 ++++++++++++++----- 4 files changed, 84 insertions(+), 39 deletions(-) diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt index 96a39e5d..e5d13b64 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt @@ -11,6 +11,7 @@ import net.rsprox.gui.dialogs.AboutDialog import net.rsprox.gui.sessions.SessionsPanel import net.rsprox.gui.sidebar.SideBar import net.rsprox.proxy.ProxyService +import net.rsprox.proxy.StartedExtensions import net.rsprox.proxy.config.BINARY_PATH import net.rsprox.proxy.config.ERROR_LOGS_PATH import java.awt.CardLayout @@ -34,7 +35,9 @@ import javax.swing.KeyStroke import javax.swing.WindowConstants.DO_NOTHING_ON_CLOSE import kotlin.system.exitProcess -public class App { +public class App( + private val extensions: StartedExtensions, +) { private val frame: JFrame = JFrame() private val sessionsPanel: SessionsPanel = SessionsPanel(this) private val launchBar: LaunchBar = LaunchBar(sessionsPanel) @@ -45,7 +48,6 @@ public class App { public val statusBar: StatusBar = StatusBar(transcriptionManager) private lateinit var sideBar: SideBar private lateinit var homePanel: HomePanel - private val mcpEnabledAtStart: Boolean = service.getMcpEnabled() public fun init() { installFileChooserStateStore() @@ -171,11 +173,11 @@ public class App { addSeparator() - val serveMcpEndpoint = JCheckBoxMenuItem("Serve MCP Endpoint", mcpEnabledAtStart) + val serveMcpEndpoint = JCheckBoxMenuItem("Serve MCP Endpoint", service.getMcpEnabled()) serveMcpEndpoint.mnemonic = 'M'.code serveMcpEndpoint.toolTipText = MCP_ENDPOINT_TOOLTIP serveMcpEndpoint.addActionListener { - this@App.setMcpEnabled(serveMcpEndpoint.isSelected) + this@App.switchMcpEndpoint(serveMcpEndpoint) } add(serveMcpEndpoint) @@ -295,14 +297,16 @@ public class App { homePanel.refreshRecentDumps() } - private fun setMcpEnabled(enabled: Boolean) { - service.setMcpEnabled(enabled) - val notice = mcpRestartNotice(mcpEnabledAtStart, enabled, service.getMcpPort()) ?: return + private fun switchMcpEndpoint(item: JCheckBoxMenuItem) { + val switch = + switchMcpEndpoint(item.isSelected, extensions::serve, extensions::stopServing, service::setMcpEnabled) + item.isSelected = switch.served + val failure = switch.failure ?: return JOptionPane.showMessageDialog( frame, - notice, + failure, "MCP Endpoint", - JOptionPane.INFORMATION_MESSAGE, + JOptionPane.ERROR_MESSAGE, ) } diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt index 7d15e163..3e67d87e 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt @@ -2,22 +2,33 @@ package net.rsprox.gui internal const val MCP_ENDPOINT_TOOLTIP: String = "No password: any program on this computer can read every session's packets, chat included. " + - "Applies after a restart." + "While off, nothing is served; sessions of clients launched in this run are kept in memory and " + + "listed again when it is turned back on." + +internal data class McpEndpointSwitch( + val served: Boolean, + val failure: String?, +) /** - * The endpoint starts and stops only with RSProx, so a switch away from what this run started with - * takes effect at the next start. Returns what to tell the user then, and null when nothing is pending. + * Turns the endpoint on or off at once, and saves the choice once it holds. An endpoint that cannot be + * served stays off with nothing saved, and the result says why. */ -internal fun mcpRestartNotice( - enabledAtStart: Boolean, +internal fun switchMcpEndpoint( enabled: Boolean, - port: Int, -): String? = - when { - enabled == enabledAtStart -> null - enabled -> - "RSProx serves the MCP endpoint on http://127.0.0.1:$port/mcp after a restart.\n\n" + - "The endpoint has no password. Any program on this computer can read the packets of every " + - "session through it, chat included." - else -> "RSProx keeps serving the MCP endpoint until it is restarted." + serve: () -> String?, + stopServing: () -> Unit, + save: (Boolean) -> Unit, +): McpEndpointSwitch { + if (!enabled) { + stopServing() + save(false) + return McpEndpointSwitch(served = false, failure = null) + } + val refusal = serve() + if (refusal != null) { + return McpEndpointSwitch(served = false, failure = refusal.replace(". ", ".\n")) } + save(true) + return McpEndpointSwitch(served = true, failure = null) +} diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt index f3b0451b..4e5d3744 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ProxyToolGui.kt @@ -29,7 +29,7 @@ public fun main(args: Array) { App.service.start(rspsJavConfigUrl, rspsModulus) { percentage, actionText, subActionText, progressText -> SplashScreen.stage(percentage, actionText, subActionText, progressText) } - ProxyExtension.startAll(App.service) + val extensions = ProxyExtension.startAll(App.service) SplashScreen.stop() SwingUtilities.invokeLater { // Disable icons in all trees. @@ -44,7 +44,7 @@ public fun main(args: Array) { // This ensures that the theme you see is the same on initial boot as well as swapping back and forth AppThemes.applyThemeEdt(App.service.getAppTheme()) - val app = App() + val app = App(extensions) app.init() app.start() } diff --git a/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt index 8e33849f..010ad65b 100644 --- a/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt +++ b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt @@ -2,30 +2,60 @@ package net.rsprox.gui import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull class McpEndpointNoticeTest { - @Test - fun `turning the endpoint on says where it is served after a restart and what it exposes`() { - assertEquals( - "RSProx serves the MCP endpoint on http://127.0.0.1:43999/mcp after a restart.\n\n" + - "The endpoint has no password. Any program on this computer can read the packets of every " + - "session through it, chat included.", - mcpRestartNotice(enabledAtStart = false, enabled = true, port = 43999), + private val calls = ArrayList() + + private fun switch( + enabled: Boolean, + refusal: String? = null, + ): McpEndpointSwitch = + switchMcpEndpoint( + enabled, + serve = { + calls += "serve" + refusal + }, + stopServing = { calls += "stop serving" }, + save = { calls += "save $it" }, ) + + @Test + fun `turning the endpoint on serves it at once and then saves the choice`() { + assertEquals(McpEndpointSwitch(served = true, failure = null), switch(enabled = true)) + assertEquals(listOf("serve", "save true"), calls) } @Test - fun `turning the endpoint off says that it is served until a restart`() { + fun `turning the endpoint off stops serving it at once and then saves the choice`() { + assertEquals(McpEndpointSwitch(served = false, failure = null), switch(enabled = false)) + assertEquals(listOf("stop serving", "save false"), calls) + } + + @Test + fun `an endpoint that cannot be served stays off, saves nothing and says why with a sentence to a line`() { + val refusal = + "The MCP endpoint is not served: 127.0.0.1:43999 cannot be bound (Address already in use). " + + "Another rsprox may hold it. Close that one." + assertEquals( - "RSProx keeps serving the MCP endpoint until it is restarted.", - mcpRestartNotice(enabledAtStart = true, enabled = false, port = 43999), + McpEndpointSwitch( + served = false, + failure = + "The MCP endpoint is not served: 127.0.0.1:43999 cannot be bound (Address already in use).\n" + + "Another rsprox may hold it.\n" + + "Close that one.", + ), + switch(enabled = true, refusal), ) + assertEquals(listOf("serve"), calls) } @Test - fun `switching back to what this run started with needs no notice`() { - assertNull(mcpRestartNotice(enabledAtStart = true, enabled = true, port = 43999)) - assertNull(mcpRestartNotice(enabledAtStart = false, enabled = false, port = 43999)) + fun `two clicks in a row leave the endpoint as the second click says`() { + switch(enabled = true) + + assertEquals(McpEndpointSwitch(served = false, failure = null), switch(enabled = false)) + assertEquals(listOf("serve", "save true", "stop serving", "save false"), calls) } } From de21f8000ddd8c50954857ef1d49d1539d6c34a4 Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:54:24 +0200 Subject: [PATCH 71/72] fix(gui): start the item unticked when the endpoint is not served The item started ticked whenever mcp.enabled was true, also when the port was taken at start-up and nothing was served. It now asks the endpoint to serve once more and is ticked only when that holds. The saved choice is left alone, so the next start tries again. Co-Authored-By: Claude Opus 5.5 --- gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt | 6 +++++- .../src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt | 9 +++++++++ .../test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt index e5d13b64..db95c7ae 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/App.kt @@ -173,7 +173,11 @@ public class App( addSeparator() - val serveMcpEndpoint = JCheckBoxMenuItem("Serve MCP Endpoint", service.getMcpEnabled()) + val serveMcpEndpoint = + JCheckBoxMenuItem( + "Serve MCP Endpoint", + isMcpEndpointServed(service.getMcpEnabled(), extensions::serve), + ) serveMcpEndpoint.mnemonic = 'M'.code serveMcpEndpoint.toolTipText = MCP_ENDPOINT_TOOLTIP serveMcpEndpoint.addActionListener { diff --git a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt index 3e67d87e..9281835e 100644 --- a/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt +++ b/gui/proxy-tool/src/main/kotlin/net/rsprox/gui/McpEndpointNotice.kt @@ -10,6 +10,15 @@ internal data class McpEndpointSwitch( val failure: String?, ) +/** + * Whether the item starts ticked. An endpoint that is on was served at start-up, unless its port was + * taken then, so it is asked once more. The saved choice is left alone, so the next start tries again. + */ +internal fun isMcpEndpointServed( + enabled: Boolean, + serve: () -> String?, +): Boolean = enabled && serve() == null + /** * Turns the endpoint on or off at once, and saves the choice once it holds. An endpoint that cannot be * served stays off with nothing saved, and the result says why. diff --git a/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt index 010ad65b..eedd7988 100644 --- a/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt +++ b/gui/proxy-tool/src/test/kotlin/net/rsprox/gui/McpEndpointNoticeTest.kt @@ -58,4 +58,11 @@ class McpEndpointNoticeTest { assertEquals(McpEndpointSwitch(served = false, failure = null), switch(enabled = false)) assertEquals(listOf("serve", "save true", "stop serving", "save false"), calls) } + + @Test + fun `the item starts ticked only for an endpoint that is on and served`() { + assertEquals(true, isMcpEndpointServed(enabled = true) { null }) + assertEquals(false, isMcpEndpointServed(enabled = true) { "127.0.0.1:43999 cannot be bound" }) + assertEquals(false, isMcpEndpointServed(enabled = false) { error("an endpoint that is off is not served") }) + } } From 34a0ec346a7f3ac41b9925f84ac13ba9d9c18f8c Mon Sep 17 00:00:00 2001 From: Cygnix Date: Thu, 8 Oct 2026 12:54:42 +0200 Subject: [PATCH 72/72] docs(mcp): say that the menu item of the GUI switches the endpoint at once The GUI section no longer asks for a restart. It says what turning the endpoint off stops and what it keeps, that the sessions are listed again with their logs when it is turned back on, that a client opened before the endpoint was first turned on is not attached, and what the item does when the port is taken. Co-Authored-By: Claude Opus 5.5 --- mcp/README.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index c81ad8f0..cc4433d4 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -67,10 +67,21 @@ enough. To run this server next to a GUI, leave the GUI's endpoint off. ./gradlew proxy ``` -The GUI serves the endpoint only after you turn it on. Tick File > Serve MCP Endpoint and restart the -GUI. From then on it serves `http://127.0.0.1:43580/mcp` at every start, from the moment the proxy has -started. Unticking the item also takes effect at the next start, so the GUI keeps serving until it is -closed. +The GUI serves the endpoint only while it is turned on. Tick File > Serve MCP Endpoint and the GUI +serves `http://127.0.0.1:43580/mcp` at once, without a restart. It then also serves at every start, +from the moment the proxy has started. + +Untick the item and the GUI stops serving at once. The port is closed and no request is answered. A +request that still runs can no longer launch a client, stop one or send one input, and a client that +such a request was launching is closed when its launch completes. + +While off, nothing is served; sessions of clients launched in this run are kept in memory and listed +again when it is turned back on. Their packets are recorded in the meantime, so the logs and cursors +continue without a gap. The clients that `session_start` launched stay open. All of it is gone when +the GUI exits. + +A client that was already open when you first turned the endpoint on in a run is not attached. Clients +that you launch from then on are. Close such a client and launch it again to attach it. The item saves `mcp.enabled` in `~/.rsprox/proxy.properties`. Edit that file only while the GUI is closed, because the GUI writes it again when it saves its own settings. @@ -81,10 +92,12 @@ closed, because the GUI writes it again when it saves its own settings. | `mcp.port` | Loopback port of the endpoint. Default 43580. The GUI has no control for it. | | `mcp.plugin` | Whether clients that `session_start` launches get the plugin. Default `true`. | -When the port is taken, by a second rsprox or by the standalone server, the GUI logs one line that -says so and runs without the endpoint. +When the port is taken, by a second rsprox or by the standalone server, the item goes back to unticked +and a dialog says so. At start-up the GUI logs one line instead, runs without the endpoint and shows +the item unticked. Tick it to try again. -Every RuneLite or native client that you launch from the GUI appears in `session_list` as a session +Every RuneLite or native client that you launch from the GUI, once the endpoint has been turned on in +the run, appears in `session_list` as a session with `"kind":"attached"`, numbered together with the sessions that `session_start` launches. An agent picks the client you mean by the `target`, the `proxyPort` and, once you are logged in, the values under `login`, such as the `world` and the display `name`. The proxy never sees a login name or a @@ -102,7 +115,7 @@ see its packets. The standalone server has no such filters and logs every packet The endpoint has no password. Any program that runs under your user account, and any other user of the machine, can reach it on loopback and read everything the log holds, including public and -private chat, for as long as the GUI runs. That is why it is off until you turn it on. +private chat, for as long as the endpoint is on. That is why it is off until you turn it on. ## Run it without a display