Repository navigation
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`./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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…lowed 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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
…olumns Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
"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 <noreply@anthropic.com>
…d 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds an MCP server to rsprox. A coding agent can use it to start a RuneLite client through rsprox, read the decoded packets of that session, and look at and drive the client. It can also read along with a client that a person launched by hand from the GUI. The goal is to let an agent check that a game server reacts correctly to what a real client sends, where it would otherwise need a hand-written protocol stub.
Nothing in this PR is a breaking change, and the GUI behaves as before until its new File > Serve MCP Endpoint item is ticked.
Never input on the official game
The tool is for testing a private server. On the official game, which is target 0 and any target whose jav_config comes from a Jagex domain, it only reads: the client's state, widgets, vars, entities, a screenshot, the camera's position, and packets.
client_login,client_click,client_type,client_interactand aclient_cameraturn are refused there. Three layers enforce that:.runelite, which is what rsprox launches the official game with. For any other connection those ops do not exist.The plugin is listed in RuneLite as "rsprox MCP bridge" and can be switched off there.
mcp.plugin=falseinproxy.properties, or--no-pluginfor the standalone server, stops the server from installing it; packets still work without it.This guards what the shipped jar and server do. A rebuilt plugin can do anything, as with any RuneLite plugin.
What it adds
./gradlew :mcp:runstarts rsprox without the GUI and serves MCP over HTTP onhttp://127.0.0.1:43580/mcp. It can run next to a GUI instance. It skips the GUI's port range, and it does not writeproxy.propertiesor the saved filter sets.The server launches clients with a small RuneLite plugin,
rsprox-mcp-bridge, installed in the client'ssideloaded-pluginsdirectory. The plugin connects back to the server over loopback. In a client the server did not launch, the plugin does nothing.There are fourteen tools:
session_start,session_stop,session_listpackets_readclient_state,client_widgets,client_vars,client_entities,client_screenshotclient_login,client_interact,client_type,client_camera,client_clicktools/liston the endpoint returns each tool with its description and argument schema.mcp/README.mdwalks through a full example.Inside the GUI: attached sessions
The GUI serves the endpoint too, while File > Serve MCP Endpoint is ticked. It is off by default, and the item switches it at once, without a restart. A client that was already open when the endpoint is first turned on is not attached; clients launched from then on are. It listens on
mcp.port(43580 by default). Each RuneLite or native client that is launched from the GUI shows up insession_listas a session of kindattached, numbered with the sessions thatsession_startlaunched. Its entry holds the target and the proxy port, and from the login on the world, the host, the local player index, the connect time, the recording's file name, the display name and the newest tick, so an agent can tell which client a person means.An attached session is read-only.
packets_readworks on it.session_stopand everyclient_*tool refuse it, because the client belongs to the person at the GUI. Its packets are what the GUI shows: a packet that the GUI's filters hide is missing from the log.The endpoint has no password and checks only the
Originheader. Whatever can reach the loopback port can read the packets of every session, chat included. That is why the GUI serves it only while it is turned on, and the menu item's tooltip says so. Unticking the item closes the port at once, and a request that still runs can no longer launch a client, stop one or send one input. The sessions of the run stay in memory and are listed again when the endpoint is turned back on, because the proxy cannot detach a packet tap from an open client; they are gone when the GUI exits.The GUI finds the endpoint through
ServiceLoader.proxygains two small interfaces,ProxyExtensionandClientListener, whichmcpimplements. The GUI's own change is one line, and its session panel is untouched. A monitor that fans out calls the GUI's monitor first and then the endpoint's, and what the endpoint's monitor throws never reaches the GUI.If the port is taken, by a second rsprox or by the standalone server, the GUI logs one line and starts without the endpoint.
How interactions work
client_interactclicks a target by identity with the real mouse. The target is an NPC, an object, a ground item, a player, an interface widget, a dialog, or a tile to walk to. The plugin finds it in the client's own lists, moves the mouse onto its clickable shape, and reads the menu the client built for that point. It presses only when the client offers the intended action. An option that is not the default goes through the right-click menu.The client decides what the click means and what to send, so the server receives what a player's click sends, including the mouse packets. Nothing calls the client's action dispatcher or writes a packet, which means a client update cannot leave the tool sending something the client no longer would.
The whole loop runs inside the plugin at the client's frame rate. It starts on a game tick and retries until a deadline, so a moving NPC gets many attempts without a round trip to the agent. A guard on
MenuOptionClickedcancels a click that the client resolves to anything other than the intended action, before a packet is sent. When the target is out of view, or drawn under an interface such as the chatbox, the loop turns the camera toward it. The client offers only Cancel at a point an interface covers, so the tool never clicks through one.After the click the server finds the client's mouse-click packet for the pressed point and returns the action packet that follows it, with the number of attempts and the tick it went out on. If the client sends no packet for a world target, the tool reports the action as dropped. An interface option that the client handles without a packet returns a note saying so.
Waits inside the loop are counted in the client's 20 ms game cycles, not in frames, so an unlocked frame rate cannot make it click twice. Ops that produce input run one at a time per client.
client_clickis a raw click at a pixel with no guard. It is the fallback for things that have no menu action.How the rest behaves
A session, such as
s1, outlives its client. The packet log belongs to the session, so stopping and relaunching the client keeps the buffered packets and continues the same sequence numbers.The packet log shows every packet in both directions, with the named values the GUI shows. It reuses the existing transcriber with filter and setting stores that hide nothing and live only in memory.
Each
client_*tool returns the packet cursor it took before it acted. Reading from that cursor returns exactly the packets the action caused.Screenshots are scaled to the size of the canvas, so a pixel in the image is a click coordinate.
The MCP endpoint is a small JSON-RPC handler on the JDK
HttpServer. It adds no MCP SDK and no new dependencies.Running without a display
RuneLite always opens a window, so a machine with no screen needs a virtual one. On Linux, run the server under Xvfb with
--software-rendering:On a virtual display the GPU plugin renders through a software OpenGL driver, and the frames read back from it are black.
--software-renderingstops the GPU plugin in each client the server launches, without changing the RuneLite profile. macOS and Windows have no virtual display, so there the client needs a real screen.Changes to existing files
Outside the new
mcpmodule and the plugin, the PR touches these:settings.gradle.ktsincludes themcpmodule, and the root project puts it on the classpath of theproxytask.ProxyServicelets a caller replacefilterSetStoreandsettingsStore, andinitializeHttpServertakes an optional target config that defaults to the selected target. It tells itsClientListeners of each RuneLite and native launch and of each client that closed.ProxyPropertygainsmcp.enabled(default false),mcp.portandmcp.plugin.SessionMonitorgainsonPacketDirection, with a default that does nothing, andSessionTrackercalls it for each packet.ProxyToolGuistarts the extensions after the proxy service, and the File menu gains the Serve MCP Endpoint item.proxy:ProxyExtension,ClientListenerandFanOutSessionMonitor.Existing callers compile unchanged.
Testing
99 automated tests in
mcp,proxyand the GUI cover the packet log's cursor rules, the endpoint over real HTTP, the tool calls as an MCP client makes them, the session failure paths, the bridge handshake, the attached sessions, the fan-out monitor, for every tool on each kind of session and target whether it is forwarded or refused, and turning the GUI's endpoint on and off, including a request that is still running when it is turned off. The tool-call tests post to the real endpoint, with a fake plugin socket that answers canned JSON and models nothing about the game.The tests do not run a game client. The branch history holds tests that ran the plugin against a faked game, and removes them again. That 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 and none by the fake. In their place there is a checklist to run against a live client after a RuneLite update, a game revision change or a plugin change, with what proves each step. It is at the end of this description.
I ran the launched-session steps of that checklist by hand against a private revision 241 server on macOS, with the GUI running next to it:
With two clients at once on two accounts, each listed the other as a player, and both acted at the same moment without interfering. Their packet logs stayed separate. The player options Follow and Trade with sent their packets, and stopping one session left the other working.
The read-only rule, the plugin switch and the GUI menu item were added after those runs and have only been run by the automated tests. Still to confirm against a live client: that a custom-target client is welcomed with drive access, that an official-target client is read-only, and that the menu item renders, switches the endpoint and reverts when the port is taken.
For the attached sessions, I started the GUI from an earlier commit of this branch, launched a client by hand, and read along from an agent:
session_listlisted the client with its login and display name,packets_readreturned its packets in both directions as I played, and a second launch got its own session after the first client was closed.Known limits
An attached session offers packets only. The
client_*tools do not reach a client that was launched by hand, not even the ones that read.While the GUI's endpoint is off, the sessions of clients launched in that run keep recording in memory.
An attached session ends when its tab is closed or its processes exit. If the launcher hands the client over and exits early, only closing the tab ends it.
RuneScape 3 and replay clients are not attached.
The plugin has no automated tests. The compiler and the live checklist are its checks.
Not exercised against a live server: ground items, a click cancelled by the guard, and the switch to the right-click menu after a cancelled left click.
The run under Xvfb on Linux arm64 was done before the interaction loop was rewritten, so it has not been repeated for the current code.
Two-step actions such as "use item on" and casting a spell on a target are untested. Dragging an item has no tool.
Walking reaches only a tile in the loaded scene that the camera can bring into view. Secondary world views such as boats are not supported. Stretched mode is refused.
A launcher that never completes its handshake is abandoned after 180 seconds, and the server then needs a restart.
Sessions write
.binrecordings into the target's usual folder.The rendezvous file is shared, so run one MCP server per user account.
Checklist 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:
session_startreturns"state":"connected"with apid, and that pid is the client process.client_loginreturns"gameState":"LOGGED_IN", andpackets_read {"origin":"proxy"}shows aLOGINrow.packets_read {"limit":20}shows packets of tick 0, with named values.client_screenshotreturns an image whose width and height equalcanvasinclient_state.client_interacton an interface button returns apacketline and"proof":"click".packets_readfrom the returned cursor shows anEVENT_MOUSE_CLICKrow before the button packet,so the click was a real one.
client_typeinto a prompt is followed by a packet that carries the typed text.client_entitieslists the NPCs and objects that the screenshot shows, with their options.client_interactwith the default option of an NPC that walks around returns itsOPNPCpacket.The
attemptscount stays low. Repeat it a few times.client_interactwith an option that is not the default, such asExamine, goes through theright-click menu: the last entry of
triedstarts withclicked row.client_interacton an object and on a tile each return their packet, and after the walkclient_stateshows the player on that tile.client_interact {"target":"dialog","option":"continue"}advances a conversation.client_interacton an NPC fails and says thatthe client offers only Cancel.
client_camera {"look_at":"npc","index":0}returns"onScreen":true, and the next screenshotshows the turned view.
session_listshowstoppedwith the reasonclient exited,and
session_startwith the samesessionbrings it back with the earlier packets still readable.Then check the attached sessions. Stop the standalone server, start the GUI with
./gradlew proxyand launch a client from it by hand:
session_listshows a session with"kind":"attached", the target and the proxy port of theclient, and no
login.session_listshowsloginwith the world, the capture file and, a tick or twolater, your display name and a
tickthat advances.packets_readon the session returns thepackets that the GUI's own panel shows.
mcp.enabled=false.session_stopandclient_stateon the session fail withnot available for an attached session, and the client keeps running.session_listshow"state":"ended". So does closing the tab ofa second client. Both sessions stay listed with their packets.
not served.
🤖 Generated with Claude Code