dpt / mcp-rector-warm
Warm-process MCP server for Rector. Keeps the Rector container bootstrapped across calls — ~9x faster per-file analysis vs cold CLI invocation. Compatible with any MCP client (Claude Desktop, Cline, Continue, Zed, custom).
Package info
github.com/Digital-Process-Tools/mcp-rector-warm
Language:Python
Type:project
pkg:composer/dpt/mcp-rector-warm
Requires
- php: >=8.2
- mcp/sdk: ^0.7.1
- rector/rector: ^2.4
- symfony/finder: ^7.4
Requires (Dev)
- phpunit/phpunit: ^10
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.6.0
- v0.5.0
- v0.4.2
- v0.4.1
- v0.4.0
- v0.2.1
- v0.2.0
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-fix/106
- dev-fix/108
- dev-fix/109
- dev-fix/115
- dev-fix/110
- dev-fix/55
- dev-fix/112
- dev-fix/113
- dev-fix/96
- dev-fix/105
- dev-fix/99
- dev-fix/93
- dev-fix/90
- dev-fix/51
- dev-fix/87
- dev-fix/81
- dev-fix/73
- dev-fix/70
- dev-fix/77
- dev-curate/20260928T070913Z
- dev-curate/20260927T203843Z
- dev-chore/create-release
- dev-docs/vscode-mcp-json
- dev-docs/src-fix-workflow
- dev-chore/release-authority-loop
- dev-chore/bump-mcp-sdk-0.7.1
This package is auto-updated.
Last update: 2026-09-29 06:03:26 UTC
README
mcp-rector-warm
Stop paying Rector's cold-start tax on every edit. A warm-process MCP server that keeps the Rector container hot. ~9× faster per call. Works with every MCP client.
Why • Install • Use it • Benchmark • Compatibility • How it works • FAQ
Why
Rector is one of the most useful tools in modern PHP — automated refactoring, type fixes, version upgrades. It is also one of the slowest to start.
Every rector process foo.php pays the same toll: autoloader bootstrap, DI container build, ruleset compile. ~3-5 seconds before a single rule fires. For agents and validators that run Rector after every edit, that cold-start cost dominates wall time.
mcp-rector-warm keeps a warm Rector container ready across calls. First call pays the boot once. Every subsequent call reuses it -- the container itself always lives in a forked worker, never in the long-lived daemon process (#31), so re-editing rector.php between calls can never crash the server.
Install
composer global require dpt/mcp-rector-warm
Makes mcp-rector-warm available on $PATH.
Requires PHP 8.2+. Pulls Rector ^2.4 as a real Composer dep (no phar gymnastics).
Use it
Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"rector": {
"command": "mcp-rector-warm",
"args": [
"--working-dir=/path/to/your/project",
"--config=/path/to/your/project/rector.php"
]
}
}
}
Restart Claude. Ask: "Run Rector on src/Foo.php".
VS Code (Copilot chat / agent mode)
VS Code 1.102+ runs MCP servers natively. Add .vscode/mcp.json to the project:
{
"servers": {
"rector": {
"type": "stdio",
"command": "${workspaceFolder}/vendor/bin/mcp-rector-warm",
"args": [
"--working-dir=${workspaceFolder}",
"--config=${workspaceFolder}/rector.php"
]
}
}
}
With composer global require, use "command": "mcp-rector-warm" instead. If Composer's vendor-dir is not vendor/, adjust the path.
This gives Rector to chat and agent mode. It does not run Rector on save or underline code in the editor. That needs a language server -- see Language server below.
Cline / Continue / Cursor / Zed / any MCP client
Same command + args shape. The server speaks plain MCP over stdio — no client-specific glue.
Standalone
mcp-rector-warm --working-dir=/path/to/project --config=/path/to/project/rector.php
Reads MCP JSON-RPC on stdin, writes responses on stdout.
Options
| Flag | Default | Meaning |
|---|---|---|
--working-dir=PATH |
current directory | chdir()s here before anything else runs; rector_process refuses any path outside it. |
--config=PATH |
Rector's own resolution (rector.php/rector.dist.php in --working-dir) |
Passed straight through to Rector; not parsed by mcp-rector-warm itself. |
--call-timeout=SECONDS |
600 |
Hard per-call deadline, independent of PHP's default_socket_timeout (#32), for a dryRun: true (analysis-only) call: a call still working past default_socket_timeout keeps going, but one that outruns --call-timeout is killed and reported as an error instead of blocking the caller forever (#58). 0 disables it. Measured on a real project: 0.3-2.4s per warm call, ~10s for the first (container-building) call — 600s never cuts off real work. This deadline never applies to a dryRun: false (write) call (#72): the kill is an unconditional SIGKILL with no grace for an in-flight file write, and Rector writes each changed file by truncating it and then writing the new content, so a kill landing mid-write would leave that file truncated with no backup. Rather than risk that, a write call is simply never bound by --call-timeout at all — trade-off: a genuinely wedged write call can now hang indefinitely, in exchange for never truncating a file it is writing. This is a real change from v0.5.0, not a return to "the pre-#58 status quo": before #58 introduced any deadline, a wedged write still timed out the socket read after PHP's default_socket_timeout (~60s) and the daemon reported it as a closed connection while continuing to serve other calls. At this release a wedged write blocks the single-threaded warm daemon indefinitely -- every later call from any client hangs too, not just the wedged one, until the daemon is restarted. |
Benchmark
Measured with tools/warm-vs-cold.py on a real private production codebase (PHP 8.2.0, Apple Silicon, v0.5.0 at 4902c3d): 20 files sampled across the tree, each run once through one warm server session and once through a fresh rector process --dry-run, cold runs serial so neither side shares CPU with the other.
| Setup | median | p95 | Notes |
|---|---|---|---|
Cold rector process, one file |
7.02s | 9.00s | autoloader + container + ruleset each time |
| mcp-rector-warm, later calls | 0.68s | 2.40s | container reused |
| mcp-rector-warm, start + handshake | 0.1s | the container is not built yet | |
| mcp-rector-warm, first call | 6.4s | builds the container: costs about one cold run, paid once per session |
~10× faster per call at the median. 20 files: 144s cold → 30s warm, first call included. All 20 warm answers matched the cold ones.
The start and first-call rows come from a separate probe: 3 fresh sessions, each starting with the same small file, which takes 5.9-7.3s cold. The first call took 6.37-6.42s each time, and a second, different file then took 0.28-0.31s. The server builds the container on the first call, not at start, so an idle server costs nothing. The first call costs about the same as running Rector once without the server.
Numbers vary with project size and rule set. The win is the cold-start amortization, not magic. Reproduce on your own project:
python3 tools/warm-vs-cold.py --project /path/to/project --files 'src/**/*.php' --limit 20 --jobs 1 --out /tmp/wvc
The script needs the Python MCP client from tests/E2E/requirements.txt, and writes the timings to report.md in --out.
Compatibility
| Client | Status |
|---|---|
| Claude Desktop | ✅ stdio MCP |
| VS Code (Copilot chat / agent mode) | ✅ stdio MCP, .vscode/mcp.json |
| Cline (VS Code) | ✅ stdio MCP |
| Continue (VS Code / JetBrains) | ✅ stdio MCP |
| Cursor | ✅ stdio MCP |
| Zed | ✅ stdio MCP |
| Custom (Python/Node/Go MCP clients) | ✅ standard protocol |
Any client that speaks MCP stdio works. No custom protocol.
Tools exposed
rector_process
Run Rector on a path.
| Argument | Type | Default | Description |
|---|---|---|---|
path |
string | required | Absolute path to file or directory under the working dir |
dryRun |
bool | true |
Preview changes only. false writes them. --call-timeout never applies to a dryRun: false call (#72) — see the --call-timeout row above for the trade-off. |
Returns:
{
"exit_code": 0,
"output": "...",
"warm_boot": true
}
warm_boot: true ⇒ container reused. false ⇒ first call (cold boot just finished).
Without pcntl (Windows), true means the call was served by a worker process booted
before it arrived -- see Windows and other PHP builds without pcntl.
Failure is reported as an MCP tool error. A rejected path (outside the
working dir), a nonexistent path, a project with no rector.php and no
--config given at startup, or an exception raised inside Rector itself all
come back as a tool result with isError: true, so an MCP host can see the
failure and surface it instead of treating a broken call as a success. The
structured details survive in structuredContent:
{
"exit_code": -1,
"output": "",
"warm_boot": false,
"error": "rector_process: path is outside the configured working directory.",
"error_class": "SecurityError",
"trace": ""
}
A missing config reports error_class: "RuntimeException" with a message naming
--config and rector.php. The daemon does not restart itself: add a
rector.php and call again, and it boots normally, since a failed boot never
marks the container warm.
The tool also declares its behavior via MCP tool annotations: readOnlyHint: false (a non-dry-run call writes files), destructiveHint: true,
idempotentHint: false, openWorldHint: false.
Language server
bin/rector-warm-lsp (#53) is a second entry point on the same warm core as
the MCP server, speaking LSP
over stdio instead of MCP -- for editors that want Rector diagnostics on save
rather than an agent calling a tool. Same --working-dir flag as
bin/mcp-rector-warm; --config works the same passive way (left in
$_SERVER['argv'] for RectorConfigsResolver to pick up).
On didOpen/didSave it runs rector_process with dryRun: true on that
file and publishes one diagnostic per changed hunk (severity Information,
source: "rector", message = the rule name(s) attributed to that hunk, or
"Rector fix" when none could be pinned to it specifically), narrowed to the
lines the hunk actually changes rather than the surrounding diff context, and
attributed to the hunk closest to where the rule reported its change. A hunk
with no real change at all (observed for a CRLF-only difference) is skipped
rather than published as a no-op fix. A failed call -- a syntax error, an
out-of-root path, or any other Rector/tool-level error -- is published too,
as an Error-severity diagnostic with no quickfix behind it, so a broken file
is never indistinguishable from a clean one (#90, #91).
textDocument/codeAction over a diagnostic offers Apply Rector: <rule> (or
Apply Rector: Rector fix for the same no-rule-pinned case) -- a
WorkspaceEdit built straight from Rector's own unified diff, no full-file
read needed -- plus a whole-file "Apply all Rector fixes" action. didClose
clears a file's diagnostics. Results are pinned to the document version that
requested them, so a stale one is discarded if a newer didSave for the same
document finishes first -- inert in the current strictly-synchronous stdio
loop (nothing can race it there today), kept as defense-in-depth for a future
async/pipelined transport.
On initialized the server also asks the client (via
client/registerCapability) to watch rector.php and composer.lock and
report changes through workspace/didChangeWatchedFiles. When one of those
files changes, every currently-open document is re-diagnosed, most-recently
opened-or-saved first (#115) -- so the document you are actively working in
gets fresh diagnostics before ones that merely happened to be opened
earlier; the total time across every open document is unchanged. The warm
worker already reloads the config on its next call on its own, but nothing
else would trigger that next call for a document the editor is not also
re-saving, so diagnostics would otherwise keep reflecting the old config
until the editor restarted the server (#101). The registration is only sent
to a client whose initialize declared
workspace.didChangeWatchedFiles.dynamicRegistration: true, as the LSP spec
requires; with any other client the config is still reloaded on the next
didSave (the warm worker compares a content hash of the config on every
call), just not pushed to documents the editor does not re-save (#105).
Out of v1 scope: unsaved buffers (Rector reads from disk), workspace-wide
scans, and workspace/configuration.
For the architecture, the design decisions behind it, correctness (the warm == cold oracle) and current benchmark numbers -- written for someone evaluating this in five minutes, see docs/lsp-for-rector-maintainers.md.
Editor setup
Every editor below spawns the same command:
rector-warm-lsp --working-dir=/path/to/project (composer-global install) or
vendor/bin/rector-warm-lsp --working-dir=/path/to/project (local clone),
filetype php, root markers composer.json / rector.php.
Each editor says whether its snippet was run against a live install, and on
which versions. Neovim and Helix were tested (macOS, against a fixture
project: a file with a pending Rector change got one diagnostic plus an
Apply Rector: ... quickfix, and a clean file got none). Sublime Text and
PhpStorm/LSP4IJ were not.
Neovim (0.11.3+, native vim.lsp.config)
Tested on Neovim 0.11.3, 0.11.4 and 0.12.5, including starting Neovim outside the project directory.
-- ~/.config/nvim/lsp/rector.lua (Neovim 0.11.3+) -- `cmd` is a function, not a static list: `--working-dir` has to be the -- resolved project root (matched against root_markers below), not whatever -- directory Neovim happened to start in. return { cmd = function(dispatchers, config) local root = config.root_dir or vim.fn.getcwd() return vim.lsp.rpc.start({ 'rector-warm-lsp', '--working-dir=' .. root }, dispatchers) end, filetypes = { 'php' }, root_markers = { 'composer.json', 'rector.php' }, }
-- init.lua vim.lsp.enable('rector')
This needs 0.11.3 or later: Neovim 0.11.0 to 0.11.2 do not pass config to a
function cmd, so the snippet fails there with
attempt to index local 'config' (a nil value). On those versions, and on
0.10, use the nvim-lspconfig setup below.
Neovim (0.10 to 0.11.2, via nvim-lspconfig)
Tested on Neovim 0.10.4, 0.11.0 and 0.12.5 with nvim-lspconfig HEAD (a9bb4d5), including starting Neovim outside the project directory. On 0.10, nvim-lspconfig warns that it is dropping 0.10 support in its v3.
Register a custom server before calling setup, using on_new_config so
cmd picks up each resolved root rather than a fixed vim.fn.getcwd():
local lspconfig = require('lspconfig') local configs = require('lspconfig.configs') if not configs.rector_warm then configs.rector_warm = { default_config = { cmd = { 'rector-warm-lsp' }, filetypes = { 'php' }, root_dir = lspconfig.util.root_pattern('composer.json', 'rector.php'), }, on_new_config = function(new_config, new_root_dir) new_config.cmd = { 'rector-warm-lsp', '--working-dir=' .. new_root_dir } end, } end lspconfig.rector_warm.setup({})
Zed
Zed's stable path for an arbitrary, non-bundled LSP is a small
language server extension
rather than a plain settings.json entry -- unlike Neovim/Helix/Sublime, there
is no documented settings.json shape here yet to snippet honestly. Filed as
a gap for a follow-up rather than guessed at.
Helix
Tested on Helix 25.07.1.
# ~/.config/helix/languages.toml [language-server.rector-warm-lsp] command = "rector-warm-lsp" args = ["--working-dir=."] [[language]] name = "php" roots = ["composer.json", "rector.php"] language-servers = ["rector-warm-lsp"]
Helix spawns language servers with the workspace root as the working
directory, so --working-dir=. resolves to it. roots is needed: Helix's
default PHP roots are composer.json / index.php, so a project that has
only a rector.php would otherwise resolve to the git root.
Open hx from the project root, or from inside the project's git
checkout. Started from a subdirectory with no .git above it, or from
outside the project, the server gets the wrong root and fails with
"No rector.php found" or "path is outside the configured working directory".
That is a limit of Helix's root search, which roots cannot fix.
language-servers = [...] replaces Helix's default PHP servers rather than
adding to them, so to keep your usual PHP server list both, e.g.
language-servers = ["intelephense", "rector-warm-lsp"] (reasoned from
Helix's docs, not run).
Sublime Text (LSP package)
Untested: not run against a live Sublime Text install. The keys below match the LSP package's documented client schema.
// LSP.sublime-settings { "clients": { "rector-warm-lsp": { "enabled": true, "command": ["rector-warm-lsp", "--working-dir=${folder}"], "selector": "source.php" } } }
${folder} is the window's first folder only (reasoned, from
window.extract_variables()): in a multi-folder window the other folders'
files are outside the working directory, and with no folder open the server
gets an unusable --working-dir.
PhpStorm / IntelliJ (LSP4IJ plugin)
Untested: not run against a live PhpStorm/LSP4IJ install; written from LSP4IJ's docs.
LSP4IJ has no project-file snippet for an ad hoc server; it is wired through
its UI: Settings > Languages & Frameworks > Language Servers > +, define
a server with command rector-warm-lsp --working-dir=$PROJECT_DIR$ and
file name pattern *.php.
How it works
Three decisions worth knowing:
-
One daemon per project, not per call -- but the container it holds always lives in a forked worker, never in the daemon itself. Working dir pins at server startup, keeping
$_SERVER['argv']clean forRectorConfigsResolver. Before every call the runner hashes the resolvedrector.php/rector.dist.php(whicheverRectorConfigsResolverwould pick), every file the config registered viawithBootstrapFiles(), AND the project'scomposer.json(withPhpSets()with no argument reads itsrequire.phponce at boot to pick its rule sets), and a changed hash on any of them tears the current worker down and forks a brand-new one, so an edit to the config, to a bootstrap file it requires, or tocomposer.json's PHP constraint takes effect on the next call rather than waiting for a restart. Booting always happens in a process that has never booted before:rector.phpis a plain PHP filerequired while building the container, and re-requireing it in a process that already required it once is a PHP fatal ("Cannot declare class/function already declared") when the config declares a class or function -- so the daemon process itself never boots the container; it only ever talks to a worker over a socket. Only the main config file, its declared bootstrap files andcomposer.jsonare hashed; arector.phpthatrequires some OTHER shared file directly (not viawithBootstrapFiles()) is a known limitation -- touch/edit the main config file too to force a reboot after changing what it includes.composer.jsonis watched wholesale, not just itsrequire.php: a project whose config never calls barewithPhpSets()still reboots on an unrelatedcomposer.jsonedit (a new dependency, a reformat), the same coarse trade-off already accepted for the main config file. The target project's ownvendor/autoload.php(if present) is loaded once per worker, the same way cold Rector's own CLI loads it for a composer-global install, so a class that only resolves through the project's Composer autoloader is visible to warm calls too; acomposer dump-autoloadmid-session is not picked up until the worker's next reboot. -
Parallel mode forcibly disabled (
--debugflag). Rector's worker fork model expects$_SERVER['argv'][0]to be the rector CLI binary. From an MCP server it isn't, so workers can't respawn. Single-thread analysis only — that's fine for the per-file edit loop this is designed for. -
Runtime-prefixed namespace handled. Rector's bundled Symfony is namespaced
RectorPrefix<date>\\Symfony\\Component\\Console\\...to avoid dependency conflicts. The runner detects the prefix at boot and resolves Application/Input/Output class names dynamically. Survives Rector version bumps. -
A missing config, a config with zero rules, or a project's own
rector.phpprinting while it loads, cannot corrupt the MCP stdout. Rector's CLI treats a missingrector.php, or one that loads fine but registers no rules or sets, as friendly onboarding, and Symfony's console output writes straight to the real stdout stream, bypassing anob_start()wrap entirely -- and a project'srector.phpcanecho, or trigger a notice/deprecation, while it loads. None of that reaches the JSON-RPC pipe: both a missing config and a config with zero registered rules are refused as a real, reported error before any of Rector's own console machinery runs (per call, not at server startup -- arector.phpfixed up later just works on the next call), config resolution and container boot run inside an output buffer, and PHP's own error display is pointed at stderr (display_errors=stderr).
Windows and other PHP builds without pcntl
PHP on Windows has no pcntl, so there is no fork (the same holds for a build that
disables pcntl_fork in disable_functions). The server is still warm there, by a
different route (#108):
right after a call returns, it starts a standby php worker process that boots the
Rector container in the background. The next call is served by that already-booted
worker, which then exits; a fresh standby starts for the call after. Each call still runs
in a container nothing else was analysed in -- the same guarantee the forked worker gives
-- so the answers match a cold rector process.
Reusing one worker process for every call was measured and rejected: 25 of the 55 E2E warm-vs-cold scenarios diverged (a dependency's edited method still answered with its old return type, and even a second, unedited file was reported as unchanged).
What that costs compared with the fork:
- The boot has to fit between calls. A call that arrives while the standby is still booting waits for the rest of that boot -- never longer than a cold run, but not warm either. On a large project (20 files, ~7s boot, PHP 8.2, Apple Silicon): 0.75s per call (p50) with 15s between calls, against 7.4s cold; back-to-back calls with no pause, 7.6s against 8.1s cold. The fork path does 0.56s back-to-back on the same files.
- The config runs once per call, as with cold Rector: side effects of loading
rector.php(clearing a cache, say) happen before every call, not once per session. - One idle
phpprocess holds a booted container between calls, as the forked worker does.
MCP_RECTOR_WARM_NO_PCNTL=cold in the server's environment turns this off: every call then
boots and runs in its own fresh php subprocess, as before #108.
FAQ
Does this replace vendor/bin/rector? No. Use it from MCP clients (Claude Desktop, agents). For one-off CLI calls the regular binary is still simpler.
Can it apply changes? Yes — pass dryRun: false. (Rector itself has no --fix flag: it writes by default and only previews with --dry-run.) This always works, regardless of --call-timeout (#72): the deadline only ever bounds a dryRun: true call. A write call is never killed by it — see the --call-timeout row's trade-off.
Why not a phar? Rector ships as a real Composer library. Phar packaging would just add a runtime cost without a benefit here.
Memory? The daemon sets memory_limit = -1 like Rector's own CLI. Idle daemon ≈ 80MB resident.
Does it survive Rector version updates? Probably. The prefix-detection scheme is forward-compatible with new RectorPrefix<date> values. Pin a Rector version in your own composer.json if you need determinism.
Credits
- Rector by Tomas Votruba and contributors — the engine doing all the real work. If you ship PHP, sponsor him.
- Model Context Protocol by Anthropic — the protocol that makes this kind of tool integration possible.
- mcp/sdk — official PHP SDK, used here for stdio transport + tool discovery.
Related
- Rector docs — config, rules, sets.
- Rector on Packagist — the upstream package.
- claude-supertool — DPT's batched-ops Claude Code companion; integrates this server as a validator.
License
Community License — see LICENSE. Built by Digital Process Tools.
