Search by

dpt / mcp-rector-warm

fdaviddpt

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

Statistics

Installs: 8 651

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 10


README

mcp-rector-warm — cold start dies. 9× faster per edit.

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.

Tests Packagist PHP License

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:

  1. 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 for RectorConfigsResolver. Before every call the runner hashes the resolved rector.php/rector.dist.php (whichever RectorConfigsResolver would pick), every file the config registered via withBootstrapFiles(), AND the project's composer.json (withPhpSets() with no argument reads its require.php once 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 to composer.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.php is a plain PHP file required 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 and composer.json are hashed; a rector.php that requires some OTHER shared file directly (not via withBootstrapFiles()) is a known limitation -- touch/edit the main config file too to force a reboot after changing what it includes. composer.json is watched wholesale, not just its require.php: a project whose config never calls bare withPhpSets() still reboots on an unrelated composer.json edit (a new dependency, a reformat), the same coarse trade-off already accepted for the main config file. The target project's own vendor/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; a composer dump-autoload mid-session is not picked up until the worker's next reboot.

  2. Parallel mode forcibly disabled (--debug flag). 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.

  3. 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.

  4. A missing config, a config with zero rules, or a project's own rector.php printing while it loads, cannot corrupt the MCP stdout. Rector's CLI treats a missing rector.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 an ob_start() wrap entirely -- and a project's rector.php can echo, 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 -- a rector.php fixed 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 php process 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

Related

License

Community License — see LICENSE. Built by Digital Process Tools.