MCP Server¶
rustgis-cli mcp runs RustGIS as a Model Context Protocol
server, so an AI agent such as Claude Code, Claude Desktop, Cursor, VS Code or Codex can make
maps with it. The agent opens data, runs any RustGIS command or geoprocessing tool, reads
attribute tables, looks at the map as an image and looks things up in this documentation.
rustgis-cli mcp --sample # the World Atlas sample
rustgis-cli mcp --data parcels.geojson # your own data (--data more than once, or --project p.rgis)
rustgis-cli mcp --yes # also allow file and network access
rustgis-cli mcp --attach [--yes] # the desktop app that is already open (its MCP Relay)
The server talks over standard input and output, so you don't start it yourself: you tell your agent how to start it (below), and the agent runs it. Each agent gets its own project, held in memory until the agent saves it.
Adding it to an agent¶
Claude Code:
claude mcp add rustgis -- rustgis-cli mcp --sample
Codex:
codex mcp add rustgis -- rustgis-cli mcp --sample
Claude Desktop (claude_desktop_config.json), Cursor (~/.cursor/mcp.json) and other clients
that use an mcpServers list:
{
"mcpServers": {
"rustgis": {
"command": "rustgis-cli",
"args": ["mcp", "--sample"]
}
}
}
VS Code (.vscode/mcp.json):
{
"servers": {
"rustgis": {
"type": "stdio",
"command": "rustgis-cli",
"args": ["mcp", "--sample"]
}
}
}
If the agent can't find rustgis-cli, give its full path (see Installation).
Then ask, for example:
- "Which 5 countries have the largest GDP? Show them on the map."
- "Buffer the cities by 200 km, colour the result by population and show me the map."
- "Load ~/data/parcels.geojson, label the parcels with their owner and export the map to
parcels.png." (this reads and writes files, so it needs
--yes)
Managing the open desktop app¶
By default each agent gets its own project in memory. To drive the project that is open in the
desktop app instead, tick Let your Agents Control RustGIS in the AI Assistant's settings. It
starts the relay and shows what an agent needs to connect: the MCP server configuration, and the
claude mcp add / codex mcp add commands. While it's ticked, attached agents may also read and
write files and use the network, without --yes. RustGIS remembers the choice and starts the
relay with the app. The MCP Relay command (Help menu, or Command Search with Ctrl+K) starts the
relay too, without that: attached agents then have the access their --yes asks for.
The relay is off until the box is ticked or that command runs. While it runs, the status bar shows MCP relay · n attached,
where n is the number of authenticated clients. For ten seconds after an attached agent runs a
tool, the label also names it (MCP relay · 1 attached · layer.addSample, in red when it failed);
hovering shows Stop MCP relay and the last call with its parameters, and clicking it stops the
relay. Agents attached through the relay are told, when they connect, that this is the project you
have open and that you watch the map change as they work.
Under it, Full access (off unless you tick it, for this session only) lifts every refusal for
agents attached with the relay's own token. They may then also run scripts, tasks and models, and
install, remove and run plugins: what you could do yourself in RustGIS. It isn't remembered: it's
off again at the next launch, and when agents are turned off. While it's on, the status bar reads
MCP relay · full access · n attached in red. Agents the app starts itself keep their own grants'
rules.
rustgis-cli mcp --attach # the open app on 127.0.0.1:8767
rustgis-cli mcp --attach 9000 --yes # another port, with file and network access
rustgis-cli mcp --attach --timeout 300 # wait up to 5 minutes for each call (5 to 600 seconds)
The port is 8767 unless the RUSTGIS_MCP_PORT environment variable names another, for the app
and for --attach alike (a second copy of RustGIS, or a port another program holds). When the
port is taken, the relay's error says so and names that variable; a value that isn't a port number
is an error too, not a quiet fall back to 8767. While the relay runs, the app
publishes endpoint.json in the relay's private folder: schema, host, port, url, pid,
started_at, the app version, the token_file path (not the token), the attach command, and
once MCP over HTTP runs, its http_url and http_token_file.
Setup scripts can read it instead of hard-coding the port. It's removed when the relay stops. With
two copies of RustGIS on different ports it describes the one started last, and a crash can leave
it behind: check that its pid is still running before trusting it.
{
"mcpServers": {
"rustgis-app": {
"command": "rustgis-cli",
"args": ["mcp", "--attach", "--yes"]
}
}
}
Attached calls run on the open app's session through the same tools and refusal policy as the
private server. One more tool works only here: snapshot with mode: "screen" captures the map as
the window shows it. The relay holds the request until the app has taken the picture, and runs
nothing else meanwhile. File and network commands need --yes, unless Let your Agents Control RustGIS
is ticked. The session is the project you have open, so an attached agent can't replace it:
project.new, project.open, project.openText, project.sample and project.fromTemplate
are refused, with or without --yes. Each agent command is a step in your own undo history,
named for the agent (agent 1, agent 2…, in the order they first call, with the relay run, so a
relay started again doesn't take an earlier run's agent for its own). An agent's edit.undo
and edit.redo work only on its own steps: when the step on top is yours (or another agent's),
they're refused, and they run on their own (run_command), not inside a run script. You can
still undo anything. Each agent also has its own active layer: when an
agent selects a layer (layer.select, or a command that makes a layer active), your selection in
the Layers pane stays as it was. Once the agent has chosen a layer (selected one, or run a command
that made one active), its commands that name no layer act on that layer, whatever you select
meanwhile; until then they act on the one you have selected.
Every tool call an attached agent makes is written to an audit log, one JSON line per call in a
file per day (2026-10-18.jsonl) in the mcp-audit folder of the app-data folder
(~/.local/share/rustgis/mcp-audit on Linux, ~/Library/Application Support/RustGIS/mcp-audit on
macOS, %LOCALAPPDATA%\RustGIS\mcp-audit on Windows). Each line holds ts, source (mcp for the relay and MCP over HTTP, file for the file
bridge),
tool, command or script when there is one, args, success, duration_s and error.
Passwords, keys and tokens in the arguments, a run script's lines included, are replaced before
they are written. On Linux and macOS the folder and files are readable by you only; on Windows they're protected by
your user profile's permissions. A batch's calls share the batch's
duration_s and are marked "batch": true. An error whose text mentions a credential (a token, a
password, a bearer…) is logged as withheld rather than risk keeping the credential. Days older than 30 are removed.
MCP over HTTP¶
While Let your Agents Control RustGIS is on, the app also serves MCP over HTTP ("Streamable
HTTP", stateless, JSON replies) on http://127.0.0.1:8768/mcp, beside the relay. The
RUSTGIS_MCP_HTTP_PORT environment variable names another port. Clients that speak HTTP connect
without rustgis-cli. Every request needs Authorization: Bearer <token>. The token is made once
and kept in mcp-http.token in the settings folder (readable by you only), so a client registered
with it keeps working across launches; delete the file to make a new one. Requests that name
another host, or that come from a web page (an Origin header), are refused before the token is
read. Calls go through the same queue, refusals and one-at-a-time rule as the relay's. HTTP clients count
as agents attached with the relay's own token: they share that agent's access, active layer and
name in the undo history, and the token lasts across launches where the relay's lasts one run.
rustgis-cli mcp-register --client claude registers it with Claude Code (claude mcp add
--transport http rustgis-live http://127.0.0.1:8768/mcp --header "Authorization: Bearer …"), and
the settings section shows that command. Without --client it prints the configuration for any
HTTP client. --name and --port change the registration's name and port. Either way the token
leaves its file: claude mcp add takes it as an argument (other users of the computer can see a
running program's arguments for the moment it runs), and the printed configuration shows it, so
keep it out of shared logs. Whoever holds the token can do, while agents are on, what an attached agent can, files and the
network included. Delete mcp-http.token to make a new one if it leaks; each registered
client then needs registering again. On Windows the token file is protected by your user profile's
permissions, not by a mode of its own.
An agent the app starts itself gets a run-scoped token instead of the relay's own:
rustgis-cli mcp --attach PORT --token-file FILE. Each such grant carries its own file and network
permission, and the app revokes it when it's done with the agent; that closes its connections and
removes its file.
The file bridge¶
For an agent that can't use MCP, or when the relay's port is taken, the app also runs scripts written to a file. It's on while Let your Agents Control RustGIS is ticked, even if the relay couldn't start: agents then stay on, and the status bar says only the file bridge is running.
- Write a script to
bridge-command.txtin the relay's private folder ($XDG_RUNTIME_DIR/rustgison Linux, arustgis-mcp-<user>folder in the temporary folder elsewhere). The settings section shows the full path. The script is in thescript.runformat: onecommand-id {json}per line,#for comments. - Once the file has stopped changing for half a second, RustGIS runs it on the open project. (To be
sure a half-written script never runs, write it to another file in the folder and rename it to
bridge-command.txt.) It runs as one undo step, with the same rules as theruntool: a refused or failing line stops it, and nothing it did to the project is kept. - The outcome is written to
bridge-result.jsonin the same folder:{success, result, lines, layersAdded, notices, error, duration_s, timestamp}. The status bar says whether it worked.
Only one RustGIS at a time runs the bridge: a second copy's is refused while the first's runs
(it holds bridge.lock). The bridge runs a script each time the file changes. Whatever the file held when the bridge started
isn't run. While the bridge is on, bridge-ready.json there holds its pid, the two files' paths,
started_at, the app version and a schema number. The folder is readable by this user only,
like the relay's token. Scripts run through the bridge are written to the audit log (source:
"file"), and Full access applies to them as to agents attached with the relay's own token. The bridge is
named file bridge in your undo history, so a script's edit.undo (alone on its line) takes back
only the bridge's own steps. It also keeps its own active layer, as an agent does.
Relay requirements¶
- Off until asked. Only
ui.mcpRelaystarts a listener: run directly, or by ticking Let your Agents Control RustGIS (also at launch, when it was left ticked).ui.mcpRelaywith no parameters or with the same port reports the address it already listens on. A different explicitportis refused until the relay is stopped.ui.mcpRelay {"stop": true}stops it and is idempotent. The default port is 8767. - Loopback only.
Relay::bindrefuses any non-loopback address before a socket is opened; the app'sui.mcpRelaybinds 127.0.0.1. - Private token. Each run draws 16 bytes from the operating system's random source,
written as 32 hex characters. The token file is
{port}.tokenin$XDG_RUNTIME_DIR/rustgiswhen that is an absolute folder, otherwise in a per-user folder under the system temporary folder. The folder must be owned by the current user and not accessible to others. The file must be a regular file owned by the current user, without group or other access. A file that is a symlink is refused.rustgis-cli mcp --attachreads it withread_token(port). The token never appears on a command line. The file is removed when the relay stops. - App-granted access.
Relay::set_allow_access(true)lets connections attached with the relay's own token use files and the network whether or not they asked; the desktop app sets it while Let your Agents Control RustGIS is ticked. This includes any process running as the same user that can read that token, not just agents launched by RustGIS. It is checked for every call. Turning the setting off stops the relay and disconnects its clients, including a relay started manually. A failed launch clears the saved setting and reports the error in the app. - Grants.
Relay::grant(access)writes a run-scoped token to{port}-{run}-{n}.tokenin the same private folder (runis random per relay run). A grant asked for after the relay stopped is refused. A connection that attaches with it gets the grant's permission. Dropping the grant removes the file, forgets the token and closes its connections. Stopping the relay removes every grant's file. - Attach first. A connection's first non-empty line must be a
rustgis/attachmessage carrying the token. It must arrive within 5 seconds, measured from connection, and be at most 4096 bytes including its newline. The token is compared without early exit. A connection that fails this is closed. Theaccessflag sets what that connection may do; the per-call refusal policy still applies. A repeat attach may changeaccess. - Handshake and idle limits. Attached clients that send nothing for 300 seconds are closed. A reply write that takes longer than 10 seconds closes the connection.
- Connection caps. At most 16 open connections, and at most 4 that have not attached. A connection beyond either cap is dropped without a reply.
- Queue cap. At most 64 messages wait for the app's frame. A request beyond that gets error
-32001(the app is busy; try again) if it expects a reply. - Bounded request waits. A request waits up to 60 seconds for the app to run it, or as long
as the client's attach asks (
timeout_s, 5 to 600 seconds;--timeouton the command line). If it is still pending then, it is cancelled and never runs; the client gets error-32002. If it is already running, the client waits one more interval of the same length. If the reply still has not come, the client gets error-32003(its outcome is unknown), and the command may still complete later. - Busy app. When the app has drawn no frame for 10 seconds since a request was queued (a file
dialog is open, a long tool is running, or the window is minimized or hidden and the system has
stopped drawing it), the request is cancelled at once and the client gets
error
-32004saying RustGIS is busy (apart from the full queue's-32001). It never runs, and the connection stays open. An app that was only idle before the request isn't busy: the request wakes it. This comes before a longtimeout_s: a stalled app answers after 10 seconds whatever the client's wait, and a client waiting less than 10 seconds gets-32002instead. Atimeout_soutside 5 to 600 is clamped to that range, and one that isn't a number is ignored (the 60-second default). - Stop. Stopping the relay, or dropping its last owner, closes the listener and every client, drops queued messages unrun, and removes the token file. A waiting request ends at once, checked every 25 milliseconds.
- Wake. A queued message or a change in the attached-client count wakes the UI thread so it draws a frame. A relay started before the first frame installs its wake on the first logic call after that frame exists.
- Shared framing. Each message is one JSON-RPC message per line. After attach, messages and
replies are read with
read_message_maxat 64 MiB (MAX_LINE), the limit counting the newline. The attach line is read at most 4096 bytes including its newline (see Attach first). After an oversized line on a socket the relay sends a -32700 error and closes that connection. On standard input the reader skips the rest of the line and continues. IO errors name neither standard input nor a socket.
Tests¶
These behavioral tests in crates/assistant/src/relay.rs check the requirements above:
- Loopback bind and command run:
relay_runs_a_command_on_the_target_session - Token required:
relay_drops_connections_without_the_token - Parse error after attach:
relay_returns_parse_error_after_attach_and_stays_usable - File commands need
--yes:relay_refuses_file_commands_without_yes - Separate active layers:
relay_keeps_the_agents_active_layer_apart_from_the_users - Agents undo only their own steps:
relay_agents_undo_only_their_own_steps - Agents of an earlier relay run aren't this run's:
relay_agents_of_another_run_cant_undo_earlier_steps - Busy app:
relay_says_the_app_is_busy_early - Client-chosen waits:
relay_waits_as_long_as_the_client_asks - Endpoint file and port variable:
relay_publishes_its_endpoint_and_names_the_port_variable - Full access, for the relay's own token only:
relay_full_access_lifts_the_refusals_for_its_own_token_only - Screen snapshots go to the app:
relay_hands_screen_snapshots_to_the_app - Snapshots without the screen:
relay_draws_snapshots_offscreen_without_the_screen - Saved snapshots need access:
relay_refuses_a_saved_snapshot_without_access - The agent's last call, for the status bar:
relay_records_the_agents_last_call - MCP over HTTP with the token:
relay::http::tests::http_clients_with_the_token_drive_the_session - HTTP refusals (no token, other host, web page, method, size):
relay::http::tests::http_refuses_requests_without_the_token_or_from_elsewhere - Stable, private HTTP token:
relay::http::tests::http_token_is_stable_and_private - Audit log:
relay_writes_each_call_to_the_audit_log - The open project stays the user's:
relay_keeps_project_commands_for_the_user - Timed-out command never runs:
relay_cancels_a_timed_out_command_so_it_never_runs - Stop cleanup:
relay_stop_closes_clients_and_removes_the_token - Last owner drop:
relay_last_owner_drop_closes_the_listener - Unauthenticated cap:
relay_caps_unauthenticated_connections - Oversized pre-attach line:
relay_refuses_an_oversized_unauthenticated_line - Token random and private:
relay_token_is_random_and_private - Client count and wake:
relay_counts_authenticated_clients_and_wakes_on_change - Stop ends a reply wait:
relay_stop_ends_a_reply_wait_and_the_command_never_runs - Running command reported as unknown:
relay_reports_a_running_command_past_its_wait_as_unknown - Handshake deadline:
relay_closes_a_trickling_handshake_at_its_deadline - App-granted access:
relay_app_lets_agents_use_files_without_yes - Grants are scoped and revocable:
relay_grants_are_scoped_and_revocable
Tools¶
| Tool | What it does |
|---|---|
list_layers |
The whole project in one call: path and unsaved changes, the view (centre, longitude/latitude extent, scale, projection), the active layer, layers in table-of-contents order (group, type, source, extent, feature count, selection, definition query, renderer, labels, fields, visibility), web tile layers, basemap, tables and layouts with their map frames. name_filter keeps the layers and tables whose name holds it; include_fields: false leaves fields out |
view_map |
The map as a PNG image, at the current view |
snapshot |
The map as a PNG with JSON metadata: mode, width, height, extent (longitude/latitude), center, scale, projection and render_settled. mode: "screen" is exactly what you see in the RustGIS window (through the relay only), taken once the map has finished drawing or after wait_s seconds (default 5, at most 30); overlays: false leaves out the map's controls, scale bar, north arrow and sketches. mode: "render" draws the same layers, view and styling offscreen at max_width (the height follows the view's shape), which works while the window is hidden. mode: "auto" (the default) takes the screen when the map is on it. save_path also writes the PNG to a file, which needs --yes |
search_commands |
Finds RustGIS commands by words: their ids, ribbon locations and parameters |
run_command |
Runs a command by id with JSON parameters; each run is one undo step. The result names the layers it added (layersAdded) and what the command said beside it (notices), including any parameters it ignored (layer.moveDown ignores 'steps' (it reads layer)) |
run |
Runs several commands in one call, as one undo step: a script in the script.run format (one command-id {json} per line, # comments). A failing line stops it, nothing it did to the project is kept, and the error names the line; files an earlier line wrote (an export) stay written. Answers {success, result, lines, layersAdded, notices, error, duration_s}, where result is the last command's. Each line follows run_command's rules, checked before any line runs |
search_tools |
Finds geoprocessing tools by words |
describe_tool |
A geoprocessing tool's parameters |
run_tool |
Runs a geoprocessing tool; its output is added to the map as a new layer (with layersAdded and notices as for run_command) |
read_features |
A layer's attribute rows, optionally only those matching a where clause, sorted |
search_docs |
Searches this documentation and returns the best matching sections |
read_doc |
Reads a documentation page or section as Markdown; offset (1-based line) and limit (default 2000 lines) page through long results using next_offset |
These are the AI Assistant's tools, plus view_map. Every page of this documentation is also a resource
(rustgis://docs/cli.md, rustgis://docs/user-guide/maps.md…) that a client can attach to a
conversation.
What the agent may do¶
Your agent asks you before each tool call, and the server adds its own limits:
- Commands that read or write files, connect to an address on the internet or run a plugin's
command run only when the server was started with
--yes, as does opening a project from text (it could bring in web layers). Without it, the agent can still work with the data you gave the server and with the built-in sample, basemaps and geoprocessing.view_mapdraws the web tile layers the project you opened already has, fetching their tiles. - Plugin management, scripts, tasks, models and the GeoLibre runner are never run from here, unless you turned on Full access for agents in the desktop app. That applies only to agents attached to it with the relay's own token.
- In private-server mode, opening, replacing and undoing in the project are allowed: the project
is the agent's own. With
--attach, these operations affect the project open in the desktop app.
Installed plugins load first, as with the other subcommands (--no-plugins skips
them). Notes such as "MCP server ready" go to standard error; standard output carries only the
protocol.
Local-agent approvals¶
Local agents launched by the assistant use Relay::grant_reviewed(auto), not an unrestricted
run grant. With automatic approval off, gated tool calls wait in a RustGIS-side queue scoped to
that grant and exact message, including all tool calls in a batch. --yes and the general relay
checkbox do not bypass it. An approval cannot revive an expired request; revoking the grant or
stopping the relay cancels pending work. Explicit automatic approval bypasses this extra queue
for that run. Ordinary MCP clients retain the access policy described above.
Stopping the relay through its command or status-bar control also turns off its remembered agent-control choice. Copyable setup commands are labelled for POSIX shells or PowerShell and quote the executable path literally; use the matching shell.