# The MCP server: `rds-mcp` Rust DICOM Station can be driven by an AI assistant through the Model Context Protocol. `rds-mcp` is a second executable, built from the same code as the viewer, with no window: an MCP client (Claude Desktop, Claude Code, or any other) launches it, and the assistant then loads, segments, registers, propagates, analyses and exports through a set of tools. The viewer is not involved and not changed; the server is a way to run the station's pipelines without clicking through them, over a whole cohort if need be. The first workflow it was built for is heart target propagation and analysis for cardiac radioablation (STAR): carry a target from the cardiac CT it was contoured on to the planning CT, follow it through the 4DCT phases, build the ITV, evaluate the dose. The `heart_target_propagation` prompt encodes that sequence. ## Setting it up 1. Build or install the server. * **Windows installer** - a tick box on the options page, *Install the MCP server*, on by default. Untick it and `rds-mcp.exe` is not written at all; the finished page prints the path when it was. `rds-setup.exe --no-mcp` is the same answer on the command line. * **Linux AppImage** - the server is inside the AppImage, and an MCP client is pointed at the AppImage rather than at a file within it: ```json { "mcpServers": { "rust-dicom-station": { "command": "/opt/rust-dicom-station.AppImage", "args": ["mcp"] } } } ``` A copy or symlink of the AppImage named `rds-mcp` also starts the server directly, for a client that will not pass an argument. Everything else - `--config`, `--check` - works as it does on the standalone binary: `./rust-dicom-station.AppImage mcp --check`. * **macOS disk image** - the server is inside the application bundle, beside the viewer, so a client is pointed at it by full path (it has spaces in it, so it needs quoting): ```json { "mcpServers": { "rust-dicom-station": { "command": "/Applications/Rust DICOM Station.app/Contents/MacOS/rds-mcp" } } } ``` Installed through Homebrew, plain `rds-mcp` works too: the cask links that same file into the prefix ([macos.md](macos.md#the-mcp-server)). * **Flatpak** - the server is a second command of the same application: `flatpak run --command=rds-mcp io.github.alexprotom.rust-dicom-station` ([flatpak.md](flatpak.md#the-mcp-server-in-a-flatpak)). Its roots must be paths the sandbox can see. * **Snap** - the server is the snap's second command, `/snap/bin/rust-dicom-station.rds-mcp`; that path is what the client runs ([snap.md](snap.md#the-mcp-server-in-a-snap)). Its roots must be folders the snap may read: under the home folder, or on removable media once that interface is connected. * **From source** - `cargo build --release --features mcp` produces `target/release/rds-mcp` beside the viewer. The viewer's *Settings ▶ MCP server* menu says whether it is present. 2. Write the configuration, `mcp.toml`, in the station's configuration folder (`%LOCALAPPDATA%\RustDICOMStation` on Windows, `~/.config/RustDICOMStation` on Linux, `~/Library/Application Support/RustDICOMStation` on macOS, `~/snap/rust-dicom-station/common/config` in the snap, `~/.var/app/io.github.alexprotom.rust-dicom-station/config/RustDICOMStation` in the Flatpak; the menu shows the exact path). Without it no workspace can be opened: ```toml roots = ["D:/studies/anonymized"] # folders that may be read output_dir = "D:/studies/rds-mcp-out" # the one folder results go to phi_policy = "refuse" # refuse | redact | allow (see below) models_dir = "" # empty: the viewer's model folder allow_model_download = false # weights are the only network use device = "auto" # auto | gpu | cpu max_open_datasets = 4 job_timeout_minutes = 60 audit_log = true # data folder /mcp/audit-YYYY-MM-DD.log volume_cache_mb = 4096 # image volumes kept between calls workflows_dir = "" # empty: the viewer's workflow folder ``` `volume_cache_mb` is the memory for series other than a workspace's displayed one (the phases of a 4D group, say), kept between calls so a phase is read from disk once rather than by every tool; past it the least used are let go. `workflows_dir` is where `list_workflows` looks for saved workflows. 3. Tell the client about it. *Settings ▶ MCP server ▶ Copy client configuration* puts the entry on the clipboard; for Claude Desktop it goes into `claude_desktop_config.json`: ```json { "mcpServers": { "rust-dicom-station": { "command": "C:\\...\\rds-mcp.exe", "args": [] } } } ``` `rds-mcp --check` reads the configuration, prints what it says on standard error, and exits, which is the quickest way to see that the file parses. Nothing in the configuration can be changed through the protocol. The assistant works inside the roots, writes only under the output folder, and never deletes. ## What the assistant can do Every entity gets a handle the assistant refers to it by: workspaces `ds1`, registrations `reg1`, 4D group registrations `greg1`, motion runs `run1`. Structures are named (`Heart`, `TARGET`), with the structure set or segmentation series added when a name repeats. Series are numbered as `describe_dataset` lists them, and default to the displayed one. | Tool | Does | |---|---| | `open_dataset`, `describe_dataset`, `list_structures`, `list_4d_groups`, `close_dataset`, `describe_session` | Open a folder (or files) under a root; see its series, 4D groups, structure sets, segmentations, doses, plans | | `list_models` | Every automatic segmentation model: key, modality, classes, sub-models, licence, whether its weights are present or what is left to download | | `segment_organs` | Any of those models on one series by `model` (`total_mr`, `mrsegmentator`, `lung_vessels`, `lungmask_lobes`, `vista3d`, ...); without it, `variant` picks TotalSegmentator CT: `fast`, `high` (with `parts` such as `cardiac`) or `preview` for the v2 weights, `fast_v3`, `high_v3`, `preview_v3`, `small_v3`, `small_high_v3` for v3; `keep` narrows to named organs, `tg263` names them by AAPM TG-263 | | `segment_body` | The patient outline, classically or model-assisted | | `combine_structures` | Union / intersect / subtract with margins in mm (uniform or per patient direction) and cleanup | | `register`, `describe_registration` | Rigid, elastix B-spline or plastimatch B-spline; `region` makes a run local to a structure of the fixed workspace; `start` refines an earlier registration; `init` says where the search starts (automatic, the identity, the centres of gravity, or the centroids of a structure contoured on both) | | `register_structures` | Registration by structures: the moving series aligned onto the fixed one by structures contoured on both, the intensities left out (each surface laid onto its partner's signed distance map, both ways). `structures` pairs them by name (`moving` when it is named differently there, `weight` for how much one counts); `dof` is `rigid` or `translation`; `refine` adds a local B-spline per structure on the distance maps (`elastix_bspline`, `plastimatch_bspline`), with `start` refining an earlier registration of the same pair; `robust_mm` makes the fit Huber-robust; `init` is `auto` (the structures' centroids), `identity`, or a structure whose centroids are matched. Returns a `reg` handle like `register`, plus the mean surface distance and Dice of every structure before and after | | `propagate` | Carry structures across a registration, to the fixed or the moving side | | `propagate_to_group` | One series onto every phase of a 4D group, one deformable registration per phase, transforms kept and reused. With `anchor` (a structure contoured on the source and on every phase, the heart say) the run is anchored on it: centroids matched, a rigid fit on the structure plus a margin, a local deformable refinement, and the anchor's Dice against each phase's own contour as the check. This is how a cardiac CT meets a 4DCT. `land` files the results as a segmentation series per phase or as contours in each phase's own structure set; `anchor_landed_as` names the anchor's own copy (`_prop` by default); `keep_shape` carries each structure as the rigid fit of the transform over it (shape and volume kept, `rigid_residual_mm` reported) | | `analyse_motion` | The 4D pipeline: tracks, amplitudes, correlation with a reference structure, per-phase QA, one ITV per target and model. The rigid model is a local fit around each structure (`local_rigid_margin_mm`, 15 by default; 0 for one global fit); a target contoured on every phase is also tracked *as contoured*, straight from the phases' own contours (`contoured`); `phases` restricts the run to some of them | | `compare_structures` | Volumes, centroid offset, Dice, HD95, mean surface distance | | `compute_dvh` | DVH curves and metrics against a dose grid, protocol constraints, CSV | | `motion_report` | An earlier run's report again, as JSON and CSV | | `export`, `export_registration` | DICOM into the output folder: SEG or RTSTRUCT, doses, plans, images; a Deformable Spatial Registration object | | `import_to_archive`, `open_in_viewer` | File an exported folder into the local archive; launch the viewer on it | | `anonymize` | An anonymized copy of a folder, written under the output folder | | `list_workflows`, `run_workflow` | The saved workflows (and the program's examples) with their steps and inputs, and a run of one: a folder under a root for each input, by its title; the reports come back as tables, the files go into a folder under the output folder, and `open_results` keeps the studies the run read open as workspaces. A *DICOM folders* input runs a batch over its subfolders | Every call that can take more than a few seconds has an `_async` twin that returns a job handle at once; `list_jobs`, `job_result` and `cancel_job` go with it. The synchronous form reports progress to the client. One computing call runs at a time; a second one is told the server is busy. The results are what the viewer would have produced: segmentations land as segmentation series bound to the image series they were made on, ITVs on the reference phase, per-phase results on their phase, and `export` writes them with the same identifiers, so the tree looks the same whether a person or an assistant made it. `open_in_viewer` on the exported folder is the way to look. A workflow runs under the same rules as the tools. Every folder it reads is under a root and passes the identity gate first (each case of a batch that its pattern takes), and under a policy other than `allow` it must be anonymized already: a workflow reads its folders as they are, so there is nothing to redact in memory. What it writes goes under the output folder (a step that names an absolute folder is refused), it reads the station's archive only when that is under a root, and it files only into the station's own archive. The folders a workflow file names are never told: they are its author's, and a folder name can carry a patient's. Two prompt and resource conveniences: the prompt `heart_target_propagation` (arguments: the three folders and the target's name) is the standard sequence for a STAR case, and the documentation pages on registration, propagation, 4D motion, DVH and structure algebra are served as resources so the assistant can read what the numbers mean. ## Patient identity never leaves Whatever the server answers ends up in a language model's context, and for most clients that context leaves the machine. The server is built so that it has nothing to leak. **The gate.** When a workspace is opened, the headers are checked against the anonymizer's own list of identifying tags (patient name, ID, birth date, contact details, physicians, institution, accession number). A workspace in which any of them holds a value the anonymizer did not write is *identifying*, and `phi_policy` decides what happens: | Policy | Behaviour | |---|---| | `refuse` (default) | The workspace is closed again. The error names the tags, never their values, and points to the `anonymize` tool. | | `redact` | The workspace opens with the identifying values replaced in memory by the anonymizer's alias, so nothing downstream (a report header, an export field) carries them. | | `allow` | The workspace opens as it is, so an export carries the study's own identifiers. Everything that leaves the process is still scrubbed. | There is no `off`. **The door.** No tool returns patient tags: `describe_dataset` does not read them. Everything else that could carry a name passes one redactor before it becomes part of a protocol frame: tool results, error messages (which quote file names), progress messages, the prompt, the resources, the audit log. The redactor knows every identifying value seen in any open workspace and replaces it, and it reports paths relative to the root they are under (`root1/4DCT`), so a folder named after the patient never appears either. Free text from DICOM files (descriptions, structure names) is capped at 64 characters, stripped of control characters, and delivered in its own JSON field, so the assistant sees it as data rather than as instructions. This is tested, not asserted: `tests/mcp_phi.rs` gives the synthetic phantom a patient name, an ID, a birth date, a physician and an institution, runs every tool against it under all three policies, including the error paths, and fails if any of those values appears in anything the server produced. A second test does the same over the real executable's standard output. **The sandbox.** Paths in tool calls are canonicalized and must lie under a configured root (or the output folder, so results can be re-opened). A path may be given the way the server reports it, under its label: `root1/4DCT`, `output/session/anonymized`, so what `anonymize` answers is exactly what `open_dataset` takes next. Output goes into a per-session folder under `output_dir`; existing files are never overwritten and no tool deletes. Model weights are not downloaded unless `allow_model_download` is on; a missing model is an error that names the viewer's model manager. The audit log records every call after redaction. ## What it is not It is not a network service: it speaks over standard input and output to the client that launched it. It has no PACS side of its own (the station's archive is served to other stations by the separate PACS server, [pacs-server.md](pacs-server.md)). It does not drive the open viewer: results are files, and `open_in_viewer` starts a viewer on them (an attach point inside the running viewer is a possible later step, and would reuse the same tools and the same safety layer, which live in the library rather than in the executable). And it is not a medical device: like the rest of the station, it is for research and QA use. ## For developers `src/mcp/` is compiled only with the `mcp` feature. `config.rs` is the operator's file; `phi.rs` the gate and the redactor; `session.rs` the open workspaces and handles; `tools/` the tools as plain functions `fn(&mut Core, Args, &Progress) -> Result` whose argument structs derive the JSON schema the client sees; `server.rs` the `rmcp` glue (transport, progress, cancellation, the `_async` jobs); `prompts.rs` the prompt and the resources. `Core::call_public` is the one entry point the server and the tests use, and the only place redaction happens. The pipelines themselves live in `src/workflow/` (the 4D motion pipeline, one volume onto every phase of a group, structures by name), shared with the viewer's tool windows, so the server and the viewer run the same code. So does the headless core under the session: `workflow::session` holds the budgeted volume cache and the filing of structures under a name rule, and `workflow::params` the choices (variant, device, method, landing) that the tools, the dialogs and the workflow steps read by the same names. `tests/workflow.rs` is the guard for that: the phantom's target moves 0 / 6 / 3 mm between phases and the pipeline has to find it. ``` cargo build --release --features mcp cargo test --features mcp --test workflow --test mcp_tools --test mcp_phi ```