MCP Server for AI Assistants
The @memlab/mcp-server
package is an MCP (Model Context Protocol)
server that wraps memlab's heap analysis APIs. It gives AI coding assistants
(Claude Code, Cursor, Windsurf, and any other MCP client) interactive tools to
load JavaScript heap snapshots, find memory leaks, and identify optimization
opportunities through natural language conversation.
Instead of clicking through the Chrome DevTools memory panel, you point the
assistant at a .heapsnapshot file and ask questions:
Load
/tmp/my-app.heapsnapshot, tell me what is growing, and show me the retainer trace for the biggest offender.
Setup
Option 1: Global install
npm install -g @memlab/mcp-server
Then add the server to your MCP client config (~/.claude.json for Claude
Code, or .mcp.json for Cursor/Windsurf):
{
"mcpServers": {
"memlab": {
"type": "stdio",
"command": "memlab-mcp",
"env": {
"NODE_OPTIONS": "--max-old-space-size=8192"
}
}
}
}
Option 2: npx (no install)
{
"mcpServers": {
"memlab": {
"type": "stdio",
"command": "npx",
"args": ["@memlab/mcp-server"],
"env": {
"NODE_OPTIONS": "--max-old-space-size=8192"
}
}
}
}
Option 3: From source
git clone https://github.com/meta/memlab.git
cd memlab
npm install
npm run build
{
"mcpServers": {
"memlab": {
"type": "stdio",
"command": "node",
"args": [
"--max-old-space-size=8192",
"/path/to/memlab/packages/mcp-server/dist/index.js"
]
}
}
}
Give Node.js enough heap
The server keeps the fully parsed graph resident: nodes, edges, dominator tree
and indexes. A full load needs several times the snapshot's on-disk size in
memory. Always start the server with --max-old-space-size=8192 or higher; the
configurations above do this for you.
To avoid an out-of-memory crash, which would lose every loaded snapshot,
memlab_load_snapshot refuses a load in three cases:
- File size: the file is larger than
max_file_size_mb. The default is 900 MB or 0.25 × the old-space limit, whichever is larger: 2,048 MB on an 8 GB server. - Node/edge count: the snapshot's node or edge count is above the load ceiling. The ceiling is derived from the heap limit the server is actually running with: about 13.9M nodes and 55.7M edges at 8 GB.
- Memory headroom: the projected working set doesn't fit in the remaining heap, even after evicting the least-recently-used resident snapshot.
Raising --max-old-space-size raises all three limits, with no other change.
force: true attempts a one-off oversized load anyway.
Command line (memlab-cli)
The package also installs memlab-cli, which runs the same tools without an
MCP client. Use it in a shell script or CI job, or when your assistant fails to
connect to the server.
memlab-cli doctor # check node, the server, and a live handshake
memlab-cli list # every tool with a one-line description
memlab-cli schema memlab_load_snapshot # a tool's input schema as JSON
memlab-cli call memlab_load_snapshot '{"file_path":"/tmp/a.heapsnapshot"}' \
memlab_app_heap '{}' # several calls in one server session
memlab-cli script steps.jsonl # one {"tool":..,"args":{..}} per line
Loading a large snapshot takes minutes, so to keep snapshots loaded across
separate invocations, run one resident server with
memlab-cli serve --socket /tmp/memlab.sock. Then pass --socket to call,
script and stop.
memlab-cli starts the server with its own --max-old-space-size, which
overrides NODE_OPTIONS. Set MEMLAB_MAX_OLD_SPACE_MB to change it. By
default it uses 60% of physical RAM (at least 8 GB), or 60% of the container's
memory limit.
Getting a heap snapshot
Both .heapsnapshot files and .heaptimeline files (snapshots recorded with
allocation tracking on) load. memlab_allocation_sites groups live objects by
the stack that allocated them in such a capture.
From Chrome DevTools
- Open DevTools (F12) and go to the Memory tab.
- Select Heap snapshot and click Take snapshot.
- Right-click the snapshot in the left panel and choose Save....
- Save the
.heapsnapshotfile somewhere the MCP server can read it.
From a live page and its workers
Start Chrome with --remote-debugging-port=9222 and ask the assistant to run
memlab_capture with an output directory. It snapshots the page and its
dedicated workers, which a page snapshot never contains. Compare the page and
workers with memlab_realm_summary. list_targets: true lists the pages to
choose from. Give each capture a label and refer to it later as
checkpoint:<label> anywhere a snapshot path is accepted, for example
memlab_diff_snapshots({before: "checkpoint:baseline", after: "checkpoint:after"}).
From Node.js
const v8 = require('v8');
const snapshot = v8.writeHeapSnapshot();
console.log(`Heap snapshot written to ${snapshot}`);
From memlab
Snapshots taken by memlab run are saved under the
working directory printed at the end of the run (see
memlab get-default-work-dir), and can be loaded directly.
A typical investigation
A memory investigation with the MCP server usually follows this shape — all of it in conversation, with the assistant picking the tools:
- Load the snapshot — "Load the heap snapshot at
/tmp/my-app.heapsnapshot." - Frame the heap — "What is this heap made of?" The assistant splits the
heap into application data, bundle source and V8 internals
(
memlab_app_heap). Then it looks at a class histogram and the biggest retainers. - Find what is growing — with a series of snapshots taken while an interaction repeats: "Which classes grow on every snapshot?"
- Leak or working set? — "Does this growth plateau, and is it given back after idle and GC?" Steady growth across a short series can still be a bounded cache or work in flight.
- Rule out noise — "Is any of this retained only by DevTools or browser extensions?"
- Explain the retention — "Show me the retainer trace for node 48231, and tell me which reference to break."
- Confirm the fix — capture a new set of snapshots after the fix and ask
whether the per-cycle growth rate went down (
memlab_verify_fix).
What the tools can answer
The server ships about 150 tools, organized by the question they answer:
| Question | Representative tools |
|---|---|
| What is this heap made of? | memlab_app_heap, memlab_memory_partition, memlab_native_contexts, memlab_quick_diagnosis, memlab_class_histogram, memlab_largest_objects, memlab_shape_histogram, memlab_what_if |
| Is anything growing — and is it a leak? | memlab_leak_report, memlab_shape_census_diff, memlab_collection_diff, memlab_ladder_probe, memlab_rate_model, memlab_settle_check, memlab_verify_fix, memlab_element_census (React elements and fibers per component, by what holds them) |
| Who owns the growth / why is X retained? | memlab_retainer_trace, memlab_retainer_summary, memlab_trace_all, memlab_react_owners, memlab_dominator_chain, memlab_pinch_points, memlab_explain_delta, memlab_dead_context_variables, memlab_scope_chains, memlab_retained_by, memlab_allocation_sites, memlab_error_callsites (where retained Errors were created, from their captured frames) |
| Is it real, or a measurement artifact? | memlab_artifact_budget, memlab_dev_artifacts, memlab_replicate, memlab_check_health, memlab_module_source (the module's served source, dev compile vs prod compile) |
| Detached DOM and event listeners | memlab_detached_dom, memlab_dom_audit, memlab_event_listener_leaks, memlab_event_registry (group_by emitter, context class and callback: whose listener records these are) |
| Collections and caches | memlab_cache_analysis, memlab_stale_collections, memlab_map_entries, memlab_weakref_census, memlab_property_distribution, memlab_model_duplicates (records that share an id: copies of a one-per-id model) |
| Strings and duplication | memlab_intern_opportunities, memlab_duplicated_strings, memlab_string_patterns, memlab_sliced_strings, memlab_duplicate_objects |
| Inspect or find specific objects | memlab_query_objects, memlab_get_node, memlab_object_shape, memlab_get_references, memlab_get_referrers, memlab_find_nodes_by_class, memlab_find_by_shape, memlab_closure_inspection |
| None of the above | memlab_eval, memlab_eval_across, memlab_auto_investigate |
| Sharing a capture | memlab_anonymize_snapshot |
| Session, batching and write-ups | memlab_load_snapshot, memlab_snapshots, memlab_batch, memlab_finding_index, memlab_analyze_run |
Every byte-size filter takes bytes or a size such as "1MB" or "500KiB"
(KB = 1000 bytes, KiB = 1024).
Ranked lists page the same way: offset skips rows, and every page ends with
"Showing a–b of N … Next page: offset: k". Ties are broken by node id, so a
given offset always shows the same rows. memlab_snapshots with
max_name_length clips node names in every tool for the rest of the session,
and table_format: "csv" renders tables as compact CSV.
The main list and lookup tools also return structuredContent: the same rows
as JSON, with node ids scoped by a snapshot handle, raw byte counts, the
filters that applied and a pagination object.
To keep the tool list short, start the server with
MEMLAB_TOOLSETS=core (or core,growth,retention, …): only those groups are
exposed, and memlab_tools({enable: "<group>"}) turns more on mid-session.
Ask the assistant to call memlab_tools to print the full index, grouped
the same way, with a one-line "use it when" for every tool. It is the fastest
way to discover a tool you didn't know to ask for. memlab_tools with a tool
and schema: true prints that tool's parameters.
Captured variables no live code can read
All closures created in one scope share one V8 Context. A large value read
only by a callback that has already run stays alive for as long as any
sibling closure does. Snapshots from V8 15.6 (Chrome 156) and later include
the source scope tree of every script. memlab_dead_context_variables checks
each Context slot against the closures and suspended async functions still on
the heap. It reports the variables nothing live can read, ranked by the bytes
they retain. Each row gives the Context and value ids, ready for
memlab_retainer_trace. For the most accurate results capture with Chrome 157
or later, which leaves out script-level let/const. Older snapshots have no
scope tree, and the tool says so.
Scope chains
A function that captures a closure from its previous call pins that call's
whole Context, and so on back to the first call. Every link is a
system / Context, so a chain that grows by one per interaction never shows
up in a class count. memlab_scope_chains follows context → captured closure
→ that closure's Context, keeping only links between Contexts of the same
function. For each function it reports the longest chain, the linking
variables and the head's id. With run_dir or paths it gives the longest
chain at every rung, so you can tell a chain that keeps growing from one
that stops.
The React Compiler's development build makes chains on its own. It wraps each
hook call in a guard closure that captures the hook's arguments, so the
compiler temporaries t0…tN end up in Contexts and cached effect closures
link each render to the previous one. A chain linked only through those
temporaries is labelled as this artifact. A production compile calls the hook
directly and does not create it: read the module from a production capture
with memlab_module_source and compare_to to confirm.
Served source
A snapshot keeps the source text of every script it loaded, so the code that
actually ran can be read back next to the objects it created.
memlab_module_source prints a module's source, or the lines matching grep.
Inside memlab_eval, helpers.moduleSource(name) returns the same spans, so a
query can print the declaration behind a finding; mode: "recipes" has a worked
example. helpers.scripts() lists every script with its source string and
helpers.stringsBySize({minLen}) the longest strings, so a search over source
text reads a hundred scripts instead of every string node.
Saved probes
A hunt on one app measures the same few populations every round. Save each
once with memlab_probes: memlab_eval code that assigns a number to
result, plus an optional expect (flat or bounded) and cap. Probes
live in named sets in ~/.memlab/probes.json. Give a set a match, a
substring of the round's URL filter, and memlab_ladder_probe,
memlab_ab_compare and memlab_analysis_battery run it without being asked,
printing OK or VIOLATED for every probe that has an expect. Any of them also
takes probes: "<set>".
memlab_probes({mode: 'save', set: 'myapp', match: 'myapp.example.com',
name: 'open_dialogs', code: "result = helpers.byClass('Dialog').length",
expect: 'flat'})
Findings index
memlab_finding_index keeps the findings of earlier rounds so a new round can
check a candidate before investigating it. A finding is keyed by its
normalized retainer path. Some populations have no stable path, so pass a
kind and its keys instead:
kind | Keys |
|---|---|
update-queue | component, and hook_ordinal or setter |
collection | class (optionally probe) |
probe | probe (optionally set) |
memlab_finding_index({action: 'record', kind: 'update-queue', round: 'r12',
keys: {component: 'ChatList', hook_ordinal: 3, setter: 'setMenuOpen'}})
list takes round, title (matches title, signature and note) and kind
as filters. cover records the combos a round drove; with run_dir it also
reads the served revision and a hash of the gating values from run.json, so
two rounds that drove the same combos against different code or gating can
be told apart.
Reading the numbers
memlab and the browser's own heap profiler read the same snapshot, but a few figures are defined or grouped differently:
- Self size is the profiler's shallow size. Retained size of a node has the same dominator-tree definition in both.
- Class retained size (
memlab_class_histogramwithinclude_retained_size) counts each byte once even when instances hold one another, like the profiler's constructor rows. Rows overlap across classes, so neither adds up down the table. - Class keys are
type::namein memlab (closure::onClick,object::Map). The profiler groups by constructor name, with(closure),(string),(array)and(system)groups. - Total is the root's retained size plus
extra_native_bytesin both. - Distance and detachedness (
memlab_get_node) match the profiler's: hops from the root with page globals at 1, and attached / detached / unknown. - Default lists in memlab leave out engine-internal node types (arrays,
code, native, synthetic) unless you ask for them by
node_type. - Production-only totals leave out memory a development build holds only
through a dev root or dev edge (React Fast Refresh registries, React
_debug*fields, devtools globals).memlab_explain_deltaandmemlab_ab_comparereport growth this way by default, with the raw figure beside it. On a dev build a single dev-only hash table can double in size partway through a run, and the raw total then grows by megabytes with no change in the app. - Warm-up is not a leak. A class that grows at every rung while each step
adds less than the one before (preloading, first-open loads) reads
SATURATINGinmemlab_leak_reportonce its last step adds under 20% of what its first did. It is not counted as a leak candidate. A mount followed by steady growth readsSTEP+LINEAR, with its post-step rate, which is the leak rate; the overall Δ/cycle overstates it. - Named dev-build patterns.
memlab_dev_artifactstags the known development-build patterns it finds:react-compiler-hook-guard,owner-stack-event-path,devtools-consoleandfast-refresh-registry. Each tag comes with its size and the tool that shows it in detail, so a round can name a pattern instead of tracing it again.
Working with more than one snapshot
Every single-snapshot tool takes an optional snapshot: a resident handle, or
a .heapsnapshot path that is loaded for that call only. The active snapshot
does not move, so you can ask about two captures side by side without
switching between calls.
Leak detection needs a trend, not a single capture. Pass keep_previous: true
when loading so earlier snapshots stay resident: each one gets a handle, and
memlab_snapshots lists them, switches the active one, or unloads the ones you
are done with. V8 keeps an object's id for its whole lifetime, so ids match
across captures of the same isolate (one tab session, no reload) and nowhere
else. memlab_diff_snapshots detects that case and matches objects by id,
listing the objects allocated between two captures that are still alive; for
captures of different isolates, switch to the right handle before reusing an
id.
Ids never carry across isolates, but a stable key does: helpers.stableKey
in memlab_eval names a collection by its path from a module scope
(Store.pendingById), which is the same in every capture. Save a
{stableKey → entries} map with helpers.save on one snapshot, read it back
on the next with helpers.load(name, {allowCrossSnapshot: true}), and diff;
memlab_eval({mode: "recipes"}) has the full example.
Within one isolate, a heap object's id also tells you when V8 first saw it:
heap objects get odd ids in increasing order, while native and synthetic nodes
get even ids derived from an address or hash. In memlab_eval,
helpers.newest(ids, n) and helpers.ageRank(id) order objects by that id, and
helpers.maxObjectId() saved on one rung splits a population on the next into
objects that already existed and objects allocated since.
Every resident snapshot holds its full graph in memory. Watch the server's
footprint with memlab_server_status and unload snapshots you no longer need.
Peek before you load
memlab_snapshot_header reads a snapshot's header — node/edge counts, capture
time, file size, native memory with no node (extra_native_bytes) — without
parsing it, and tells you whether it fits under the
current load ceiling. Use it to pick a loadable capture in one step instead of
attempting an oversized load.
Snapshot ladders
The growth tools work on a ladder: an ordered series of snapshots captured
while one interaction repeats. Most of them load each rung transiently and
release it, so a long ladder doesn't have to fit in memory at once.
memlab_analysis_battery runs several of them over the same rungs, so it
keeps rungs parsed between its steps while memory allows
(MEMLAB_RETAIN_RUNGS caps how many). It writes battery.done.json to its
output directory last, so a script can wait on that one file;
memlab_battery_status({battery_id, wait_ms}) waits for it from the assistant
side, and wait_for_rungs: "all" queues a battery until the round's
round.done marker appears. On an A/B round (run.json has an ab section
and the ladder splits between the arms), the battery runs each ladder step
once per arm, writes <tool>.off.txt and <tool>.on.txt, and adds one
memlab_ab_compare verdict table. You can
give a ladder in three ways:
paths: an ordered list of snapshot files. Cycle counts are read fromrung_NN_cNNN.heapsnapshotfilenames; otherwise rungs are assumed evenly spaced, and the report says so.run_dir: a directory whoserun.jsonlists the rungs, the cycles driven at each rung, and an optional settle rung captured after idle and GC. The settle rung is what tells a leak apart from work still in flight.ladder:<name>: a name registered once withmemlab_ladder.
memlab_eval_across takes paths, or run_dir with rungs: [0, -1], to run
one eval program over chosen rungs the same way, without loading them first.
Ladder tools can run longer than an assistant's tool timeout. Start them with
memlab_start_job and collect the result with memlab_job_status. A large
snapshot can be loaded the same way with memlab_load_snapshot({async: true}). Every call
also runs under a wall-clock budget (90 s by default; override it with
timeout_ms or MEMLAB_ANALYSIS_TIMEOUT_MS), so a runaway scan returns a clean
"analysis stopped" result instead of wedging the server.
Tool permissions and safety
No tool ever modifies the heap snapshot it analyzes, and most tools are
read-only analysis. Two groups are exceptions. Start the server with
MEMLAB_DISABLE_CODE_TOOLS=1 to turn off the tools that run JavaScript
altogether. Every tool declares MCP
annotations (readOnlyHint, destructiveHint, idempotentHint,
openWorldHint and a title), so a client that honours them can auto-approve
the read-only tools and keep asking for these.
Do not auto-approve tools that execute JavaScript
These tools run JavaScript passed in by the assistant, or read from a round directory's files, with the full privileges of the MCP server process:
memlab_evalandmemlab_eval_acrossmemlab_for_eachmemlab_hypothesismemlab_ladder_probeandmemlab_replicatememlab_judges,memlab_calibrate_judgesandmemlab_switch_matrixmemlab_ab_comparememlab_analysis_battery, when the round holds aninvariants.json
memlab_batch and memlab_start_job can call any of them.
They use node:vm to scope the globals they inject, but
node:vm is not a security sandbox. Code
can reach the host realm and from there the filesystem, network and shell.
Heap snapshot contents are attacker-influenceable input, so a prompt injection
could steer the model into running hostile code. Keep these tools on manual
approval, and avoid a mcp__memlab__* wildcard allowlist, which would
auto-approve them too.
A second group has side effects outside the heap. memlab_capture connects to
a running Chrome and writes snapshots. memlab_prune_run compresses or deletes
a round's snapshot files; its default mode is a dry run. memlab_anonymize_snapshot
and memlab_analysis_battery write files. memlab_finding_index, memlab_metric
and memlab_ladder keep small state files under your home directory.
To stop your assistant prompting on every call while keeping these on manual
approval, allowlist the read-only tools you use explicitly. For Claude Code,
put the list in ~/.claude/settings.json (user-level) or .claude/settings.json
(project-level):
{
"permissions": {
"allow": [
"mcp__memlab__memlab_tools",
"mcp__memlab__memlab_load_snapshot",
"mcp__memlab__memlab_snapshot_header",
"mcp__memlab__memlab_snapshots",
"mcp__memlab__memlab_app_heap",
"mcp__memlab__memlab_snapshot_summary",
"mcp__memlab__memlab_class_histogram",
"mcp__memlab__memlab_retainer_trace"
]
}
}
Further reading
- MCP server README
— the full tool reference (every tool, grouped by the question it answers),
plus the
memlab-clicommands, the state files the server writes, and allMEMLAB_*environment variables. - MCP investigation skill — a structured methodology for assistants: step-by-step triage, guided investigation paths for string waste, DOM leaks, object accumulation, closure leaks and listener accumulation, plus token-efficiency tips.
- AI assistant guide — guidance for AI assistants using memlab more broadly, including writing test scenarios and reading retainer traces.