metro-mcp-plugin-lifecycle
v0.1.1
Published
metro-mcp plugin: own the Metro dev-server process — start/stop/restart with --reset-cache, bundle smoke check, duplicate-React diagnostics.
Maintainers
Readme
metro-mcp-plugin-lifecycle
A plugin for metro-mcp that lets an MCP-aware AI agent own the Metro dev-server process for a React Native / Expo project — start it, stop it, restart it with --reset-cache, and run static checks against it.
metro-mcp itself is an attach-only debugger: once Metro is running, it gives the agent access to the app's console, network, Redux state, component tree, and profiler over Chrome DevTools Protocol. It does not, however, start or stop Metro — that has to happen out-of-band, in a terminal the agent can't see. This plugin closes that gap so the agent can drive the whole loop end-to-end.
metro_start— spawnyarn start(ornpm/pnpm/bun), wait forinitialize_done, return ownership info.metro_stop— terminate only the Metro process this plugin spawned (PID + start-time + recorded command + cwd validation, process group SIGTERM → SIGINT → SIGKILL with per-step revalidation).metro_restart— sequential stop then start, optional--reset-cache.metro_bundle_smoke— request the bundle URL for one or more platform/dev combinations and return parsed Metro errors as structured JSON (bundle bodies are never returned).metro_diagnose_duplicate_react— pure-Node realpath walk acrossnode_modules, workspaces, andfile:/link:deps to find duplicate React installations (a common cause ofCannot read property 'useRef' of null).metro_get_start_log— bounded stdout/stderr ring buffer from the spawned Metro process. Critical when Metro fails before its/eventsWebSocket comes up (e.g. badmetro.config.js).
Why a separate plugin
| metro-mcp covers | This plugin covers |
|---|---|
| Running app: console, network, errors, Redux state, components, profiler | Metro process: start, stop, restart, reset-cache |
| Read-only /status + /events consumption | Process spawn, signal management, ownership locks |
| symbolicate, reload_app (Hermes-level) | bundle_smoke, duplicate-React static diagnostics |
The two are complementary, not competitive.
Install
npm install --save-dev metro-mcp-plugin-lifecycle
# or
yarn add -D metro-mcp-plugin-lifecycleThen register it with metro-mcp in metro-mcp.config.ts:
import { defineConfig } from 'metro-mcp';
export default defineConfig({
plugins: ['metro-mcp-plugin-lifecycle'],
});Or via CLI flag:
bunx metro-mcp --plugin metro-mcp-plugin-lifecycleOr environment variable (colon-separated for multiple plugins):
METRO_MCP_PLUGINS=metro-mcp-plugin-lifecycle bunx metro-mcpUsage
Once the plugin is registered, start metro-mcp the way you normally do (CLI / config / your MCP client's mcpServers entry). The agent can then call:
metro_start({ cwd: '/path/to/your/rn-or-expo-project' })cwd defaults to the metro-mcp host process's working directory; pass it explicitly if your MCP client launches metro-mcp from elsewhere. The project must have a start script that runs Metro and forwards CLI args (the default React Native / Expo template already does — react-native start "$@" or expo start "$@").
Tools
metro_start
metro_start({
resetCache?: boolean, // default false
port?: number, // default 8081
watchFolders?: string[], // extra folders passed via --watchFolders
packageManager?: 'yarn' | 'npm' | 'pnpm' | 'bun', // default auto-detect from lockfile
waitForReady?: boolean, // default true
timeoutMs?: number, // default 60000
cwd?: string, // default process.cwd()
}) → {
pid, port, ready,
ownership: 'plugin' | 'external',
alreadyRunning: boolean,
cwd, command, statusUrl, lockPath, reason
}Idempotent: if Metro is already responding on the requested port and the lock matches, returns the existing state instead of spawning a duplicate.
Refuses to start in several unsafe / conflicting cases:
- A Metro this plugin owns is already running for this project on a different port (would orphan the existing process).
- The port hosts an external Metro for a different project (would conflict).
- The port hosts a non-Metro process (would never respond to
/status).
If waitForReady is true and Metro fails to become ready (timeout, or another project's Metro takes the port mid-spawn), the spawned process group is killed and the lock is removed before throwing — no orphans, no stale lock.
metro_stop
metro_stop({
force?: boolean, // default false; skip SIGTERM/SIGINT and go straight to SIGKILL
timeoutMs?: number, // default 5000; per-step budget
cwd?: string,
}) → { stopped, pid, port, reason, finalSignal, steps }Annotations: destructiveHint: true.
Validates identity before sending any signal — PID alive, start time matches the lock, and the recorded command's executable still matches the running command (catches PID reuse + lock hijack). /status is not required, so a Metro that's still booting or hung can still be stopped.
Sends SIGTERM to the process group, then re-checks the leader PID; if it's gone, probes /status to catch the "leader exited but workers still own the port" case. Escalates through SIGINT to SIGKILL only as long as something on either side is still alive.
Only Metros this plugin started can be stopped — externally started Metros are detected (so we won't start a duplicate on the same port) but never signalled.
If identity validation fails (PID dead or claimed by something unrelated), cleans up the lock without sending signals.
metro_restart
metro_restart({
resetCache?: boolean,
port?: number,
watchFolders?: string[],
packageManager?: 'yarn' | 'npm' | 'pnpm' | 'bun',
timeoutMs?: number,
cwd?: string,
}) → { stopped, started, pid, port, ready, reason }Sequential metro_stop then metro_start via shared primitives — same ownership rules and ready detection as the standalone tools.
metro_bundle_smoke
metro_bundle_smoke({
preset?: 'quick' | 'release' | 'full', // shortcuts
entries?: string[], // default ['index']
platforms?: ('ios' | 'android' | 'web')[], // default ['ios']
dev?: boolean[], // default [false]
minify?: boolean,
}) → {
resolved, totalMs, okCount, failCount,
results: Array<{
entry, platform, dev, minify,
ok, timingMs, httpStatus, sizeBytes,
errors?: Array<{ type, message, file?, line?, column? }>
}>
}Presets:
quick— single combo (ios + dev=true); fastestrelease—ios+android×dev=false+minify=truefull—ios+android×dev=trueanddev=false
Bundle bodies are not returned (can be many MB). Only metadata + parsed errors (errors[] shape from Metro's formatBundlingError).
metro_diagnose_duplicate_react
metro_diagnose_duplicate_react({
includePackageManagerWhy?: boolean, // optional `yarn why react` / `npm ls react` evidence
watchFolders?: string[], // extra roots (relative paths resolve against cwd)
cwd?: string,
}) → {
severity: 'ok' | 'warning' | 'error',
summary,
occurrences: Array<{ version, realpath, packageRoot, importPath }>,
searchedRoots,
evidence?: { tool, output }
}Searches:
<cwd>/node_moduleswatchFolders/*/node_modules- Workspace roots declared in root
package.json file:/link:dependency targets (and their nestednode_modules)
Severity:
ok— single realpathwarning— single version, multiple realpaths (e.g. symlinks to the same physical install, or Metro bundling two copies)error— multiple versions, or no React at all
metro_get_start_log
metro_get_start_log({
lines?: number, // default 200, max 500
stream?: 'stdout' | 'stderr' | 'both', // default 'both'
cwd?: string,
}) → { stdout, stderr, truncated: { stdout, stderr }, available, reason }Bounded ring buffer (capacity 500 lines per stream) of recent stdout/stderr from the Metro process spawned by metro_start. Useful when Metro fails to fully start (bad config, port conflict before /status, dependency error during initialize_started).
Buffer lives in the metro-mcp host process's memory: only Metros started in the current session are observable. A Metro spawned by a previous metro-mcp run, even if it's still owned by this plugin via the lock, has no log buffer.
Architecture notes
- Ownership lock:
~/.cache/metro-mcp-plugin-lifecycle/<project-hash>.jsonrecords{ pid, pgid, port, cwdRealpath, command, pidStartTime, startedAt, statusFingerprint }.metro_stoprequires every field to still match the live system before sending signals — a stale or hijacked lock cannot accidentally kill an unrelated process. - Process spawning: direct
child_process.spawnwithdetached: true(process group), notctx.exec(one-shot, returns a string — unsuitable for long-running processes). - Host cleanup: the plugin installs a one-time
SIGINT/SIGTERM/exithandler on the metro-mcp host process so spawned Metros are killed if the host exits cleanly or is interrupted. A hard crash orSIGKILLof the host bypasses this — recovery in that case relies on the nextmetro_startdetecting and refusing the stale state. - Ready signal: poll
http://<host>:<port>/statusuntil it returnspackager-status:runningAND theX-React-Native-Project-Rootheader matches our realpath. Fails fast if a different project's Metro takes the port. - Port state resolver: a 9-state classifier (
ours_running,ours_other_port,external_metro_same_project,external_metro_other_project,external_metro_unknown_project,port_taken_not_metro,stale_lock_port_free,stale_lock_external_metro,free) so every caller deals with the awkward cases explicitly instead of silently overwriting.
Known limitations (v0.1.0)
- Not safe under concurrent
metro_startcalls. Lock acquisition is read-decide-write — two simultaneous starts can both seefreeand race. In practice MCP agents serialise tool calls within a session, so this is rarely hit. A true file-lock will land in a later version. - Workspace globs (
packages/*) are not expanded bymetro_diagnose_duplicate_react. Literal workspace paths andfile:/link:deps are followed; globs are skipped. Pass the workspace roots explicitly viawatchFoldersif you need them searched.
Requirements
- Node.js ≥ 20
metro-mcp≥ 0.11 (pre-1.0; minor versions may introduce breaking changes)- A React Native or Expo project with Metro
- macOS or Linux. Windows is untested — process identity uses
ps -o lstart=and negative-PGID signalling, both POSIX-only.
License
MIT
