Skip to main content
Glama
juliodelimas

jmeter-mcp-server

by juliodelimas
README.md
# jmeter-mcp-server

[![npm version](https://img.shields.io/npm/v/jmeter-mcp-server.svg)](https://www.npmjs.com/package/jmeter-mcp-server)
[![CI](https://github.com/juliodelimas/jmeter-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/juliodelimas/jmeter-mcp-server/actions/workflows/ci.yml)
[![Node.js](https://img.shields.io/node/v/jmeter-mcp-server.svg)](https://www.npmjs.com/package/jmeter-mcp-server)
[![TypeScript](https://img.shields.io/badge/TypeScript-5%2B-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/npm/l/jmeter-mcp-server.svg)](LICENSE)

Give an LLM real, deterministic control over [Apache JMeter](https://jmeter.apache.org/) — build test plans, run real load tests, and read back real results, without ever hand-writing `.jmx` XML or opening the GUI.

```
"Load test my API: 20 users hitting POST /orders for 2 minutes,
 5% think time, fail anything over 800ms"
```

...turns into a running JMeter test and a real report, through typed tool calls an MCP client (Claude Code, Claude Desktop, etc.) makes directly.

## Why not just ask an LLM to write the `.jmx` itself?

It can — a `.jmx` is just XML, and any capable model has seen plenty of JMeter test plans. The problem is *how* it fails: JMeter's format is a `hashTree` with dozens of fragile, easy-to-misremember details — exact `guiclass`/`testclass` pairs, property names that don't match their GUI label (`ThreadGroup.num_threads` is a `stringProp`, not an `intProp`), integer *bitmasks* for assertion match types, strict parent/child pairing with sibling `<hashTree>` tags. None of it is self-checking. A wrong value still produces valid, loadable XML that just quietly does the wrong thing.

That's not hypothetical — it happened building this project. An early version of the If Controller generated a property called `useExpression` set to `true`, which reads like "yes, evaluate my condition." The real JMeter source does the opposite: `useExpression=true` means *don't* evaluate it as an expression — just check if the string is literally `"true"`. Every non-trivial condition silently, permanently failed. No error, no warning — the child sampler just never ran. It only surfaced by actually executing the generated plan against real JMeter and noticing a sample count of zero.

That's the whole case for this server in one story: an LLM regenerating XML from memory re-risks that exact mistake on every single request. This server encodes the correct shape **once**, in a serializer (and a matching parser for the reverse direction) checked against real JMeter source and bundled examples, exercised by [166 automated tests](#testing) including real JMeter runs — and exposes it as typed tools instead. Concretely:

- **Correctness through one tested code path**, not regenerated-from-memory XML every time.
- **Cheap incremental edits.** Plans are a small JSON tree with stable node ids — adding, removing, renaming, moving, or disabling an element is one tool call by `id`, not rewriting a whole `.jmx` file. An existing `.jmx` (hand-written or exported from the GUI) can be imported and edited the same way.
- **Aggregated results, not raw samples.** `get_execution_report` returns computed stats (error %, avg/median/p90/p95/p99, throughput) — not thousands of sample rows to average by hand.
- **Real async execution.** `execute_test_plan` returns immediately with an `executionId`; long-running load tests never block anything.

The generated `.jmx` is standard JMeter output — open it in the real GUI any time.

## Example

```
You:    Build a load test: 10 users for 30s hitting GET https://api.example.com/health,
        fail anything that takes over 500ms, then run it and tell me the p95.

Claude: [create_test_plan, add_thread_group, add_http_sampler, add_duration_assertion,
         add_aggregate_report_listener, execute_test_plan, get_execution_status, get_execution_report]

        Ran 300 requests over 30s, 0 failures. p95 latency: 214ms, avg: 187ms, throughput: 10.1 req/s.
```

Every step above is a real typed MCP tool call — see [Tools](#tools) for the full set (34 element types across samplers, controllers, timers, extractors, assertions, and listeners, plus editing, inspection, and `.jmx` import/export tools) and [Example workflow](#example-workflow) for the raw call sequence.

## Quick start

```bash
claude mcp add jmeter \
  -e JMETER_HOME=/opt/homebrew/opt/jmeter/libexec \
  -- npx -y jmeter-mcp-server
```

That's it — no cloning, no build. Adjust `JMETER_HOME` to your JMeter install (see [Prerequisites](#prerequisites)). Full setup details, Claude Desktop config, and local-dev instructions are in [Adding this server to Claude Code](#adding-this-server-to-claude-code).

## How a test plan is represented

Each plan is a JSON tree (`{id, type, props, children[]}`), not XML text. Authoring tools append a child under a given `parentId`, editing tools (`remove_element`, `update_element`, `move_element`, etc.) mutate that same tree in place, and the tree is only serialized to a real `.jmx` file on demand (`get_test_plan_xml`) or at execution time. `import_test_plan` runs the reverse direction, parsing an existing `.jmx` back into this same tree shape. This is what makes incremental edits cheap and keeps all the fiddly XML schema knowledge in two places (`src/jmx/serializer.ts` for tree → XML, `src/jmx/parser.ts` for XML → tree, sharing prop shapes from `src/jmx/propTypes.ts`) instead of spread across every tool.

## Tools

**Authoring** (each returns the new node's `id`, used as `parentId` for
whatever you attach under it next) — grouped the same way JMeter's own
right-click **Add** menu groups them, so if you already know the GUI, you
already know where to look:

| Tool | Adds |
|---|---|
| `create_test_plan` | Root `TestPlan` node — returns `planId` and the root node id |

**Threads (Users):**

| Tool | Adds |
|---|---|
| `add_thread_group` | Thread Group (virtual users) |
| `add_setup_thread_group` | setUp Thread Group (runs once before all Thread Groups) |
| `add_teardown_thread_group` | tearDown Thread Group (runs once after all Thread Groups) |

**Sampler:**

| Tool | Adds |
|---|---|
| `add_http_sampler` | HTTP Request sampler |
| `add_jdbc_request` | JDBC Request sampler |
| `add_jsr223_sampler` | JSR223 Sampler (Groovy/BeanShell/JS/JEXL script as the sample) |
| `add_ftp_request` | FTP Request sampler |
| `add_tcp_sampler` | TCP Sampler |

**Logic Controller:**

| Tool | Adds |
|---|---|
| `add_transaction_controller` | Transaction Controller (groups child samplers into one named transaction) |
| `add_loop_controller` | Loop Controller (repeats child samplers) |
| `add_if_controller` | If Controller (conditionally runs child samplers) |
| `add_while_controller` | While Controller (repeats children while a condition holds) |
| `add_random_controller` | Random Controller (runs one random child per pass) |
| `add_interleave_controller` | Interleave Controller (alternates through children) |

**Config Element:**

| Tool | Adds |
|---|---|
| `add_csv_data_set` | CSV Data Set Config (parameterization from a file) |
| `add_user_defined_variables` | User Defined Variables |
| `add_jdbc_connection_configuration` | JDBC Connection Configuration (pooled datasource) |
| `add_http_request_defaults` | HTTP Request Defaults |
| `add_cookie_manager` | HTTP Cookie Manager |
| `add_header_manager` | HTTP Header Manager |

**Timer:**

| Tool | Adds |
|---|---|
| `add_constant_timer` | Constant Timer (pacing/think-time) |
| `add_uniform_random_timer` | Uniform Random Timer (randomized pacing) |
| `add_constant_throughput_timer` | Constant Throughput Timer (target rate pacing) |

**Pre Processors:**

| Tool | Adds |
|---|---|
| `add_jsr223_preprocessor` | JSR223 PreProcessor |
| `add_user_parameters` | User Parameters (per-thread variable value sets) |

**Post Processors:**

| Tool | Adds |
|---|---|
| `add_json_extractor` | JSON Extractor post-processor |
| `add_regex_extractor` | Regular Expression Extractor post-processor |
| `add_xpath_extractor` | XPath Extractor post-processor |
| `add_jsr223_postprocessor` | JSR223 PostProcessor |

**Assertions:**

| Tool | Adds |
|---|---|
| `add_response_assertion` | Response Assertion |
| `add_json_assertion` | JSON Assertion (JSONPath validation) |
| `add_duration_assertion` | Duration Assertion (response-time SLA) |
| `add_size_assertion` | Size Assertion (response byte-size check) |

**Listener:**

| Tool | Adds |
|---|---|
| `add_aggregate_report_listener` | Aggregate Report listener |
| `add_summary_report_listener` | Summary Report listener |
| `add_view_results_tree_listener` | View Results Tree listener (full request/response capture for debugging) |
| `add_backend_listener` | Backend Listener (streams live metrics to InfluxDB/Graphite/etc.) |

**Editing** (mutate an already-built plan):

| Tool | Purpose |
|---|---|
| `remove_element` | Remove an element (and its subtree); rejects removing the root `TestPlan` node |
| `update_element` | Shallow-merge (or replace) a node's props; a prop value of `null` deletes that key. Validated against the node's type when known |
| `rename_element` | Rename an element's `testname` |
| `move_element` | Move an element (and its subtree) to a new parent, optionally at a specific index; rejects moving a node into its own subtree |
| `reorder_children` | Reorder a node's direct children (must pass an exact permutation of the current children) |
| `set_element_enabled` | Enable/disable an element without removing it |

**Inspection:**

| Tool | Purpose |
|---|---|
| `list_test_plans` | List every plan in the workspace |
| `get_test_plan` | Full element tree of a plan, including every node's `id` |
| `get_test_plan_xml` | Serialize a plan to its JMeter `.jmx` XML, without running JMeter |
| `import_test_plan` | Import an externally authored `.jmx` (e.g. exported from the JMeter GUI) as a new plan. Element types this server doesn't model are kept as opaque `UnknownElement` nodes instead of being dropped |

**Execution & reporting** (async — a run happens in the background):

| Tool | Purpose |
|---|---|
| `execute_test_plan` | Serialize to `.jmx` and run JMeter in non-GUI mode; returns `{ executionId }` immediately |
| `get_execution_status` | `running` / `completed` / `failed`, plus a tail of the JMeter log |
| `stop_execution` | Send `SIGTERM` to a running JMeter process |
| `get_execution_report` | Aggregated stats (per label + overall) parsed from the run's JTL output |

## Example workflow

```
create_test_plan            → { planId, rootNodeId }
add_thread_group             (parentId: rootNodeId)  → { nodeId: threadGroupId }
add_http_sampler              (parentId: threadGroupId) → { nodeId: samplerId }
add_response_assertion        (parentId: samplerId)
add_aggregate_report_listener (parentId: threadGroupId)
execute_test_plan             (planId) → { executionId }
get_execution_status           (executionId)   ← poll until "completed"
get_execution_report            (executionId) → aggregated latency/error stats
```

## Testing

166 automated tests, no framework beyond Node's built-in test runner:

```bash
npm test               # 155 tests: tree-mutation and XML-shape unit tests, XML -> tree parsing,
                        # serialize -> parse round-trips, and every tool called over the real MCP
                        # protocol (stdio, the same way Claude Code/Desktop talk to it) - no
                        # JMeter install needed, fully hermetic
npm run test:integration  # 11 tests: real JMeter runs - the If Controller story above, While
                        # Controller loop counts, timer pacing, extractors, assertions, etc.
                        # (needs JMETER_HOME)
npm run test:all
```

`npm test` spawns the actual built server (`dist/index.js`) via `StdioClientTransport` and drives it exactly as a real client would — not just calling internal functions — so a broken tool schema or a malformed response shows up as a real protocol error, not a passing unit test.

Both suites run on every push and pull request via [GitHub Actions](.github/workflows/ci.yml) — the integration job installs a real JMeter binary on the runner, so it's exercising the same code path as a local run, not a mock.

## Prerequisites

- Node.js 18+
- JMeter installed locally, with the `JMETER_HOME` environment variable
  pointing at the installation directory (the one containing `bin/jmeter`).
  On macOS via Homebrew, `brew install jmeter` puts it at
  `/opt/homebrew/opt/jmeter/libexec`.

## Adding this server to Claude Code

### Via npx (recommended — published on npm)

No cloning or building required; `npx` fetches and runs the published
version on the fly:

```bash
claude mcp add jmeter \
  -e JMETER_HOME=/opt/homebrew/opt/jmeter/libexec \
  -- npx -y jmeter-mcp-server
```

Adjust the `JMETER_HOME` path to wherever JMeter is installed on your
machine. Optionally set `JMETER_MCP_WORKSPACE` too (see below) if you want
plans and executions stored somewhere other than the default.

The default scope is `local` (this project directory only). To make it
available across every project, add `-s user`:

```bash
claude mcp add jmeter -s user \
  -e JMETER_HOME=/opt/homebrew/opt/jmeter/libexec \
  -- npx -y jmeter-mcp-server
```

Confirm it registered and is responding:

```bash
claude mcp list
```

### From a local clone (development)

If you're working on this repository's code instead of using the published
package, point at the built `dist/index.js` directly:

```bash
npm install
npm run build
claude mcp add jmeter \
  -e JMETER_HOME=/opt/homebrew/opt/jmeter/libexec \
  -- node /absolute/path/to/jmeter-mcp-server/dist/index.js
```

### Claude Desktop

Add this to `~/Library/Application Support/Claude/claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "jmeter": {
      "command": "npx",
      "args": ["-y", "jmeter-mcp-server"],
      "env": {
        "JMETER_HOME": "/opt/homebrew/opt/jmeter/libexec"
      }
    }
  }
}
```

Note: unlike a terminal-launched app, Claude Desktop does **not** inherit
environment variables exported in your shell profile (`.zshrc`, etc.) — only
true system-wide ones. Always set `JMETER_HOME` explicitly in the `env`
block above rather than relying on it already being "set on your machine".

## Environment variables

| Variable | Required | Purpose |
|---|---|---|
| `JMETER_HOME` | Yes | JMeter installation directory (must contain `bin/jmeter`) |
| `JMETER_MCP_WORKSPACE` | No | Where plans and executions are stored. Defaults to `./jmeter-workspace` relative to wherever the server process starts |

## Workspace layout

```
<workspace>/
  plans/<planId>/plan.json           # JSON tree — source of truth for a plan
  executions/<executionId>/
    generated.jmx                    # serialized at execute_test_plan time
    aggregate-report.jtl             # output of the Aggregate Report listener, if present
    summary-report.jtl               # output of the Summary Report listener, if present
    jmeter.log
    meta.json                        # execution status, pid, timestamps, exit code
```

## Editing and importing plans

Beyond the `add_*` authoring tools, a plan can be mutated after the fact
(`remove_element`, `update_element`, `rename_element`, `move_element`,
`reorder_children`, `set_element_enabled`) and an externally authored `.jmx`
(e.g. exported from the JMeter GUI) can be brought in with `import_test_plan`.
`import_test_plan` understands the most common element types (thread groups,
HTTP samplers, assertions, extractors, controllers, config elements, the
three report listeners, etc.); anything it doesn't recognize is kept as an
opaque `UnknownElement` node whose original XML is preserved and re-emitted
as-is by `get_test_plan_xml`/`execute_test_plan`, instead of being dropped -
`import_test_plan`'s response reports `unknownElementCount`/
`unknownElementTypes` so you know what wasn't fully understood. Coverage can
be extended incrementally in `src/jmx/parser.ts`.

## v1 scope

Not yet supported (candidates for a future release): generating the HTML
dashboard report (`-e -o`), parent-type validation on `add_*`/`move_element`/
`import_test_plan` (nothing stops attaching an element under a semantically
wrong parent), distributed execution.

Note on `add_csv_data_set`: the `filename` must be an absolute path.
`execute_test_plan` runs JMeter from a fresh per-execution directory, so a
relative path (which JMeter's GUI would resolve against the `.jmx` file's
own location) won't resolve there. An absolute path baked into a plan is
also machine-specific — it won't travel if you share `plan.json` with
someone on a different machine. This is only checked at creation time:
`add_csv_data_set` rejects a relative or nonexistent path up front, but
later changing a `CSVDataSet`'s `filename` via `update_element`, or
importing a `.jmx` that already has a relative one via `import_test_plan`,
is not checked - it will only surface as a failure at `execute_test_plan`
time.

Note on `add_jdbc_request`/`add_jdbc_connection_configuration`,
`add_ftp_request`, and `add_backend_listener`: these generate correct,
JMeter-loadable XML, but exercising them for real needs infrastructure this
project doesn't provide (a database, an FTP server, an InfluxDB/Graphite
instance) — they were verified structurally, not against a real backend.

Note on `add_view_results_tree_listener`'s `captureFullData` option: it has
no effect right now. `execute_test_plan` always runs JMeter with
`-Jjmeter.save.saveservice.output_format=csv`, and JMeter's CSV writer never
emits response body/header columns no matter what the `SampleSaveConfiguration`
flags say — only its XML output format can carry full response bodies. The
option is wired up correctly in the generated `.jmx` (verified: the flags
really do flip in the XML) for the day this server supports XML-format runs,
but until then it's a no-op — confirmed by running a real capture and
checking the resulting JTL has no `responseData`/`samplerData`/
`requestHeaders`/`responseHeaders` columns regardless of the setting.

Note on `add_tcp_sampler`: `server`/`port`/`request` are live-verified. The
numeric fields (`connectTimeoutMs`, `timeoutMs`) are rendered as
`stringProp` following this project's general convention for sampler
numeric fields, but that specific choice for `TCPSampler` wasn't confirmed
against a real JMeter-GUI-saved example (none was available to check
against) — flagging in case a real save turns out to expect `intProp`.

## Roadmap

Ideas being explored for future releases — none of these are implemented yet:

| Proposed tool | What it does | Why it's worth it |
| --- | --- | --- |
| `find_breaking_point` | Automatic binary-search capacity finder: ramps thread count up/down on its own, run after run, until it finds the concurrency level that violates your SLA (p95 latency, error %) | JMeter has no native "find the limit" feature. Driving this search through raw LLM tool calls costs ~6-8 calls per round (adjust load, run, poll, read report, decide) across the several rounds a binary search needs |
| `detect_bottleneck_class` | Fits Little's Law / the Universal Scalability Law to collected concurrency vs. throughput vs. latency data, and classifies the bottleneck as contention, coherency, or saturation | JMeter only outputs raw numbers; re-deriving a queueing-theory curve fit through prose reasoning would mean reimplementing nonlinear regression by hand for every question |
| `detect_soak_drift` | Runs linear regression over the latency/error time series of a long-duration soak test to separate normal noise from a real trend (the classic memory-leak signal) | JMeter's graph shows the curve, but doesn't say whether it's a statistically real degradation or just noise |
| `compare_execution_reports` | Statistical diff across N executions (not just two), with a significance test for whether a p95 shift is real or noise | A ready-made performance regression gate for CI, instead of someone eyeballing two JSON reports and guessing |
| `isolate_warmup_window` | Automatically detects where warm-up (JIT, connection pools, cold caches) ends, and recomputes metrics only over the steady-state window | Today's overall average is polluted by the first few seconds of a run; JMeter doesn't separate this on its own |
| `classify_error_flakiness` | Re-runs failed samples with backoff and separates "real system error" from "one-off flake" (network timeout, etc.) | Produces a trustworthy error rate for a CI gate, instead of an `errorPct` that mixes both kinds together |
| `orchestrate_distributed_run` | Runs the same test plan across multiple JMeter injector nodes (master-slave, via `-R` or independent engines) and merges every node's `.jtl` into a single aggregated report | JMeter supports distributed mode, but wiring up remote engines, RMI ports, matching JMeter versions, and merging results across machines is entirely manual today — nobody sets this up for a quick test |

## How this compares

Checked against the other public JMeter MCP servers found on GitHub as of September 3, 2026 —
open-source repositories with at least a README description (undocumented forks/clones excluded).
Columns reflect what each project's own README documents, not independent verification of its
internals.

| Server | Approach | `.jmx` round-trip | Edit by id | Async run | Tests | Real JMeter |
| --- | --- | --- | --- | --- | --- | --- |
| **jmeter-mcp-server** (this project) | JSON tree, 34 element types, authored and edited by node id | ✓ | ✓ | ✓ | 166, incl. real runs | ✓ |
| [QAInsights/jmeter-mcp-server](https://github.com/QAInsights/jmeter-mcp-server) | Runs an existing `.jmx` and analyzes results — doesn't author plans | — | ✗ | ✗ | ✗ | ✓ |
| [aravindksk7/Jmeter-MCP](https://github.com/aravindksk7/Jmeter-MCP) | Generates a `.jmx` from parameters; no re-import of existing plans | ✗ | ✗ | ✗ | ✗ | ✓ |
| [chandanvars/jmeter-mcp-server](https://github.com/chandanvars/jmeter-mcp-server) | Generates a whole plan from one JSON payload, runs it via Docker | ✗ | ✗ | ✗ | ✗ | ✓ |
| [perfsage/perfsage-jmeter-mcp](https://github.com/perfsage/perfsage-jmeter-mcp) | Heals the local JMeter runtime, imports HAR/OpenAPI traffic, discovers capacity | — | ✗ | ✗ | ✗ | ✓ |
| [shruthi-r18/ClaudeCode_MCP_QA_Automation_Performance](https://github.com/shruthi-r18/ClaudeCode_MCP_QA_Automation_Performance) | Demo project for a tutorial video, 11 fixed tools | ✗ | ✗ | ✗ | ✗ | ✓ |
| [KenLin-7/jmeter-mcp-server](https://github.com/KenLin-7/jmeter-mcp-server) | Reimplements HTTP load testing in Node — no Apache JMeter underneath | ✗ | ✗ | ✗ | ✗ | ✗ |
| [MUYU0615/jmeter_mcp_server](https://github.com/MUYU0615/jmeter_mcp_server) | Generates a `.jmx` with basic parameters (threads, ramp-up, duration) | ✗ | ✗ | ✗ | ✗ | ✓ |
| [vjgit-369/JmeterDemoUsingMCP](https://github.com/vjgit-369/JmeterDemoUsingMCP) | Demo script with parameters hardcoded in source, not a general-purpose server | ✗ | ✗ | ✗ | ✗ | ✓ |

Every capability in that table shows up somewhere across the other eight projects, individually:
real JMeter execution, `.jmx` generation, traffic import. This is the only one that combines full
`.jmx` round-trip (import *and* export), per-id incremental editing, non-blocking async execution,
and a documented automated test suite in one place.

## License

MIT

TDQS

A3.5/5.0

Scored across 14 tools

Disambiguation5/5

Each tool targets a distinct action and resource: test plan lifecycle (create/list/get), adding different element types (thread group, sampler, extractor, etc.), and execution/reporting (execute/status/report/stop). Despite similar 'add_' prefixes, each add tool clearly specifies the element it creates, leaving no ambiguity about which tool to use.

Naming Consistency5/5

All tools follow a consistent verb_noun pattern in snake_case: create_, list_, get_, add_, execute_, stop_. The naming convention is uniform and predictable, making it easy for an agent to infer tool purpose from the name alone.

Tool Count5/5

With 14 tools, the server covers the full test plan workflow—creation, building, execution, and result retrieval—without being bloated. Each tool serves a clear purpose, and the count is well within the typical range for a focused domain.

Completeness3/5

The set supports creating and reading test plans, adding common elements, and running/stopping executions, but lacks update and delete operations for both plans and elements. This means agents cannot modify or remove existing configurations, forcing recreation of a plan for any change. While the core create-run-report cycle is covered, the absence of edit/removal capabilities is a notable gap.

Maintenance

ActivityMaintained
ResponsivenessResponsive