Skip to content

Add an MCP server for driving and observing a client through rsprox - #87

Open
CygnixDev wants to merge 72 commits into
blurite:masterfrom
CygnixDev:mcp-server
Open

CygnixDev wants to merge 72 commits into
blurite:masterfrom
CygnixDev:mcp-server

Conversation

@CygnixDev

@CygnixDev CygnixDev commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

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_interact and a client_camera turn are refused there. Three layers enforce that:

  • Each tool declares whether it reads or drives, and a session of the official game has read access only. The server refuses a drive tool before anything is sent.
  • The link the server holds to the plugin carries the session's access and forwards no input op on a read-only one.
  • The plugin builds its input ops, and the mouse they use, only when the server's welcome grants input and the client's home directory is not the stock .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=false in proxy.properties, or --no-plugin for 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:run starts rsprox without the GUI and serves MCP over HTTP on http://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 write proxy.properties or the saved filter sets.

The server launches clients with a small RuneLite plugin, rsprox-mcp-bridge, installed in the client's sideloaded-plugins directory. 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:

Tools What they do
session_start, session_stop, session_list Launch, stop and list clients.
packets_read Read decoded packets after a cursor, and optionally wait for one to arrive.
client_state, client_widgets, client_vars, client_entities, client_screenshot Read the client.
client_login, client_interact, client_type, client_camera, client_click Drive the client.

tools/list on the endpoint returns each tool with its description and argument schema. mcp/README.md walks 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 in session_list as a session of kind attached, numbered with the sessions that session_start launched. 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_read works on it. session_stop and every client_* 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 Origin header. 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. proxy gains two small interfaces, ProxyExtension and ClientListener, which mcp implements. 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_interact clicks 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 MenuOptionClicked cancels 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_click is 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:

xvfb-run -s "-screen 0 1280x800x24" ./gradlew :mcp:run --args="--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-rendering stops 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 mcp module and the plugin, the PR touches these:

  • settings.gradle.kts includes the mcp module, and the root project puts it on the classpath of the proxy task.
  • ProxyService lets a caller replace filterSetStore and settingsStore, and initializeHttpServer takes an optional target config that defaults to the selected target. It tells its ClientListeners of each RuneLite and native launch and of each client that closed.
  • ProxyProperty gains mcp.enabled (default false), mcp.port and mcp.plugin.
  • SessionMonitor gains onPacketDirection, with a default that does nothing, and SessionTracker calls it for each packet.
  • ProxyToolGui starts the extensions after the proxy service, and the File menu gains the Serve MCP Endpoint item.
  • New in proxy: ProxyExtension, ClientListener and FanOutSessionMonitor.

Existing callers compile unchanged.

Testing

99 automated tests in mcp, proxy and 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:

  • Session start, login, and logout through the game's own button.
  • Interface buttons, typed input, and a conversation continued step by step.
  • An NPC that walks around, a right-click option, an object, and walking to a tile.
  • The camera, read, set and pointed at a target.
  • The error when an interface blocks the game view.
  • A walk to a tile drawn under the chatbox, where the loop turned the camera and then succeeded.
  • A client killed by pid, and a session relaunched with its earlier packets still readable.

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_list listed the client with its login and display name, packets_read returned 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 .bin recordings 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:

  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.

🤖 Generated with Claude Code

Cygnix and others added 30 commits October 3, 2026 15:39
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>
Cygnix and others added 30 commits October 8, 2026 11:25
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant